mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-07 09:21:24 +08: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"
|
"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",
|
"identity" : "snapkit",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
@@ -10,24 +10,26 @@ import UIKit
|
|||||||
|
|
||||||
extension AFFiNEViewController: IntelligentsButtonDelegate {
|
extension AFFiNEViewController: IntelligentsButtonDelegate {
|
||||||
func onIntelligentsButtonTapped(_ button: IntelligentsButton) {
|
func onIntelligentsButtonTapped(_ button: IntelligentsButton) {
|
||||||
IntelligentContext.shared.webView = webView!
|
// if it shows up then we are ready to go
|
||||||
button.beginProgress()
|
let controller = IntelligentsController()
|
||||||
|
self.present(controller, animated: true)
|
||||||
IntelligentContext.shared.preparePresent { result in
|
// IntelligentContext.shared.webView = webView
|
||||||
button.stopProgress()
|
// button.beginProgress()
|
||||||
switch result {
|
// IntelligentContext.shared.preparePresent { result in
|
||||||
case .success:
|
// DispatchQueue.main.async {
|
||||||
let controller = IntelligentsController()
|
// button.stopProgress()
|
||||||
self.present(controller, animated: true)
|
// switch result {
|
||||||
case let .failure(failure):
|
// case .success:
|
||||||
let alert = UIAlertController(
|
// case let .failure(failure):
|
||||||
title: "Error",
|
// let alert = UIAlertController(
|
||||||
message: failure.localizedDescription,
|
// title: "Error",
|
||||||
preferredStyle: .alert
|
// message: failure.localizedDescription,
|
||||||
)
|
// preferredStyle: .alert
|
||||||
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
// )
|
||||||
self.present(alert, animated: true)
|
// alert.addAction(UIAlertAction(title: "OK", style: .default))
|
||||||
}
|
// self.present(alert, animated: true)
|
||||||
}
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import Intelligents
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
class AFFiNEViewController: CAPBridgeViewController {
|
class AFFiNEViewController: CAPBridgeViewController {
|
||||||
|
var intelligentsButton: IntelligentsButton?
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
webView?.allowsBackForwardNavigationGestures = true
|
webView?.allowsBackForwardNavigationGestures = true
|
||||||
@@ -11,6 +13,7 @@ class AFFiNEViewController: CAPBridgeViewController {
|
|||||||
edgesForExtendedLayout = []
|
edgesForExtendedLayout = []
|
||||||
let intelligentsButton = installIntelligentsButton()
|
let intelligentsButton = installIntelligentsButton()
|
||||||
intelligentsButton.delegate = self
|
intelligentsButton.delegate = self
|
||||||
|
self.intelligentsButton = intelligentsButton
|
||||||
dismissIntelligentsButton()
|
dismissIntelligentsButton()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,11 +38,39 @@ class AFFiNEViewController: CAPBridgeViewController {
|
|||||||
plugins.forEach { bridge?.registerPluginInstance($0) }
|
plugins.forEach { bridge?.registerPluginInstance($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var intelligentsButtonTimer: Timer?
|
||||||
|
private var isCheckingIntelligentEligibility = false
|
||||||
|
|
||||||
override func viewDidAppear(_ animated: Bool) {
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
super.viewDidAppear(animated)
|
super.viewDidAppear(animated)
|
||||||
|
IntelligentContext.shared.webView = webView
|
||||||
navigationController?.setNavigationBarHidden(false, animated: animated)
|
navigationController?.setNavigationBarHidden(false, animated: animated)
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
let timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { [weak self] _ in
|
||||||
self.presentIntelligentsButton()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+141
@@ -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"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
@@ -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/devxoul/Then", from: "3.0.0"),
|
||||||
.package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.7.1"),
|
.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/SwifterSwift/SwifterSwift.git", from: "6.0.0"),
|
||||||
|
.package(url: "https://github.com/loopwork-ai/eventsource.git", from: "1.1.1"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(name: "Intelligents", dependencies: [
|
.target(name: "Intelligents", dependencies: [
|
||||||
@@ -28,6 +29,7 @@ let package = Package(
|
|||||||
"SwifterSwift",
|
"SwifterSwift",
|
||||||
.product(name: "Apollo", package: "apollo-ios"),
|
.product(name: "Apollo", package: "apollo-ios"),
|
||||||
.product(name: "OrderedCollections", package: "swift-collections"),
|
.product(name: "OrderedCollections", package: "swift-collections"),
|
||||||
|
.product(name: "EventSource", package: "eventsource"),
|
||||||
], resources: [
|
], resources: [
|
||||||
.process("Interface/View/InputBox/InputBox.xcassets"),
|
.process("Interface/View/InputBox/InputBox.xcassets"),
|
||||||
.process("Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets"),
|
.process("Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets"),
|
||||||
|
|||||||
+31
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -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 {}
|
||||||
-125
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-44
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+138
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
-74
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+53
-198
@@ -7,221 +7,76 @@
|
|||||||
|
|
||||||
import AffineGraphQL
|
import AffineGraphQL
|
||||||
import Apollo
|
import Apollo
|
||||||
|
import ApolloAPI
|
||||||
import Combine
|
import Combine
|
||||||
|
import EventSource
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OrderedCollections
|
||||||
|
|
||||||
// MARK: - ChatManager
|
public class ChatManager: ObservableObject, @unchecked Sendable {
|
||||||
|
|
||||||
public class ChatManager: ObservableObject {
|
|
||||||
public static let shared = ChatManager()
|
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] = []
|
var closable: [Closable] = []
|
||||||
@Published public private(set) var currentSession: SessionViewModel?
|
|
||||||
@Published public private(set) var messages: [String: [ChatMessage]] = [:]
|
|
||||||
@Published public private(set) var isLoading = false
|
|
||||||
@Published public private(set) var error: Error?
|
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private init() {}
|
||||||
private let apolloClient: ApolloClient
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
public func closeAll() {
|
||||||
|
closable.forEach { $0.close() }
|
||||||
private init(apolloClient: ApolloClient = QLService.shared.client) {
|
closable.removeAll()
|
||||||
self.apolloClient = apolloClient
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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(
|
public func with<T>(sessionId: String, vmId: UUID, _ action: (inout T) -> Void) {
|
||||||
workspaceId: String,
|
with(sessionId: sessionId) { sessionViewModels in
|
||||||
promptName: String = "",
|
if let read = sessionViewModels[vmId], var convert = read as? T {
|
||||||
docId: String? = nil,
|
action(&convert)
|
||||||
pinned: Bool = false
|
guard let vm = convert as? any ChatCellViewModel else {
|
||||||
) async throws -> SessionViewModel {
|
assertionFailure()
|
||||||
isLoading = true
|
return
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
sessionViewModels[vmId] = vm
|
||||||
|
} else {
|
||||||
|
assertionFailure()
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
await MainActor.run {
|
|
||||||
self.error = error
|
|
||||||
self.isLoading = false
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func sendMessage(
|
@discardableResult
|
||||||
content: String,
|
public func append(sessionId: String, _ viewModel: any ChatCellViewModel) -> UUID {
|
||||||
attachments: [String] = [],
|
with(sessionId: sessionId) { $0.updateValue(viewModel, forKey: viewModel.id) }
|
||||||
sessionId: String? = nil
|
return viewModel.id
|
||||||
) async throws {
|
}
|
||||||
guard let targetSessionId = sessionId ?? currentSession?.id else {
|
|
||||||
throw ChatError.noActiveSession
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading = true
|
@discardableResult
|
||||||
error = nil
|
public func report(_ sessionID: String, _ error: Error) -> UUID {
|
||||||
|
let model = ErrorCellViewModel(
|
||||||
// Add user message immediately
|
id: .init(),
|
||||||
let userMessage = ChatMessage(
|
errorMessage: error.localizedDescription
|
||||||
id: UUID().uuidString,
|
|
||||||
role: .user,
|
|
||||||
content: content,
|
|
||||||
attachments: attachments.isEmpty ? nil : attachments,
|
|
||||||
params: nil,
|
|
||||||
createdAt: DateTime(date: Date())
|
|
||||||
)
|
)
|
||||||
|
append(sessionId: sessionID, model)
|
||||||
await MainActor.run {
|
return model.id
|
||||||
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)"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-43
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// ChatMessage.swift
|
// ChatSessionObject.swift
|
||||||
// Intelligents
|
// Intelligents
|
||||||
//
|
//
|
||||||
// Created by 秋星桥 on 6/26/25.
|
// Created by 秋星桥 on 6/26/25.
|
||||||
@@ -8,48 +8,7 @@
|
|||||||
import AffineGraphQL
|
import AffineGraphQL
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct ChatMessage: Codable, Identifiable, Equatable, Hashable {
|
public struct ChatSessionObject: Codable, Identifiable, Equatable, Hashable {
|
||||||
public var id: String?
|
|
||||||
public var role: MessageRole
|
|
||||||
public var content: String
|
|
||||||
public var attachments: [String]?
|
|
||||||
public var params: [String: String]?
|
|
||||||
public var createdAt: DateTime?
|
|
||||||
|
|
||||||
public var createdDate: Date? {
|
|
||||||
createdAt?.decoded
|
|
||||||
}
|
|
||||||
|
|
||||||
public var messageId: String {
|
|
||||||
id ?? UUID().uuidString
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(
|
|
||||||
id: String? = nil,
|
|
||||||
role: MessageRole,
|
|
||||||
content: String,
|
|
||||||
attachments: [String]? = nil,
|
|
||||||
params: [String: String]? = nil,
|
|
||||||
createdAt: DateTime? = nil
|
|
||||||
) {
|
|
||||||
self.id = id
|
|
||||||
self.role = role
|
|
||||||
self.content = content
|
|
||||||
self.attachments = attachments
|
|
||||||
self.params = params
|
|
||||||
self.createdAt = createdAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension ChatMessage {
|
|
||||||
enum MessageRole: String, Codable, CaseIterable {
|
|
||||||
case user
|
|
||||||
case assistant
|
|
||||||
case system
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct SessionViewModel: Codable, Identifiable, Equatable, Hashable {
|
|
||||||
public var id: String
|
public var id: String
|
||||||
public var workspaceId: String
|
public var workspaceId: String
|
||||||
public var docId: String?
|
public var docId: String?
|
||||||
+29
@@ -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"
|
||||||
|
}
|
||||||
+57
@@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
-12
@@ -42,6 +42,9 @@ public class IntelligentContext {
|
|||||||
case currentI18nLocale
|
case currentI18nLocale
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public private(set) var currentSession: ChatSessionObject?
|
||||||
|
public private(set) var currentWorkspaceId: String?
|
||||||
|
|
||||||
public lazy var temporaryDirectory: URL = {
|
public lazy var temporaryDirectory: URL = {
|
||||||
let tempDir = FileManager.default.temporaryDirectory
|
let tempDir = FileManager.default.temporaryDirectory
|
||||||
return tempDir.appendingPathComponent("IntelligentContext")
|
return tempDir.appendingPathComponent("IntelligentContext")
|
||||||
@@ -49,11 +52,14 @@ public class IntelligentContext {
|
|||||||
|
|
||||||
public enum IntelligentError: Error, LocalizedError {
|
public enum IntelligentError: Error, LocalizedError {
|
||||||
case loginRequired(String)
|
case loginRequired(String)
|
||||||
|
case sessionCreationFailed(String)
|
||||||
|
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case let .loginRequired(reason):
|
case let .loginRequired(reason):
|
||||||
"Login required: \(reason)"
|
"Login required: \(reason)"
|
||||||
|
case let .sessionCreationFailed(reason):
|
||||||
|
"Session creation failed: \(reason)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,6 +67,7 @@ public class IntelligentContext {
|
|||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
public func preparePresent(_ completion: @escaping (Result<Void, Error>) -> Void) {
|
public func preparePresent(_ completion: @escaping (Result<Void, Error>) -> Void) {
|
||||||
|
assert(webView != nil)
|
||||||
DispatchQueue.global(qos: .userInitiated).async { [self] in
|
DispatchQueue.global(qos: .userInitiated).async { [self] in
|
||||||
prepareTemporaryDirectory()
|
prepareTemporaryDirectory()
|
||||||
|
|
||||||
@@ -79,21 +86,18 @@ public class IntelligentContext {
|
|||||||
!baseUrlString.isEmpty,
|
!baseUrlString.isEmpty,
|
||||||
let url = URL(string: baseUrlString)
|
let url = URL(string: baseUrlString)
|
||||||
else {
|
else {
|
||||||
DispatchQueue.main.async {
|
completion(.failure(IntelligentError.loginRequired("Missing server base URL")))
|
||||||
completion(.failure(IntelligentError.loginRequired("Missing server base URL")))
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let workspaceId = webViewMetadataResult[.currentWorkspaceId] as? String,
|
guard let workspaceId = webViewMetadataResult[.currentWorkspaceId] as? String,
|
||||||
!workspaceId.isEmpty
|
!workspaceId.isEmpty
|
||||||
else {
|
else {
|
||||||
DispatchQueue.main.async {
|
completion(.failure(IntelligentError.loginRequired("Missing workspace ID")))
|
||||||
completion(.failure(IntelligentError.loginRequired("Missing workspace ID")))
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentWorkspaceId = workspaceId
|
||||||
QLService.shared.setEndpoint(base: url)
|
QLService.shared.setEndpoint(base: url)
|
||||||
|
|
||||||
let gqlGroup = DispatchGroup()
|
let gqlGroup = DispatchGroup()
|
||||||
@@ -106,20 +110,28 @@ public class IntelligentContext {
|
|||||||
gqlGroup.wait()
|
gqlGroup.wait()
|
||||||
qlMetadata = gqlMetadataResult
|
qlMetadata = gqlMetadataResult
|
||||||
|
|
||||||
// Check required QL metadata
|
|
||||||
guard let userIdentifier = gqlMetadataResult[.userIdentifierKey] as? String,
|
guard let userIdentifier = gqlMetadataResult[.userIdentifierKey] as? String,
|
||||||
!userIdentifier.isEmpty
|
!userIdentifier.isEmpty
|
||||||
else {
|
else {
|
||||||
DispatchQueue.main.async {
|
completion(.failure(IntelligentError.loginRequired("Missing user identifier")))
|
||||||
completion(.failure(IntelligentError.loginRequired("Missing user identifier")))
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let currentDocumentId: String? = webViewMetadata[.currentDocId] as? String
|
||||||
|
|
||||||
dumpMetadataContents()
|
dumpMetadataContents()
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
createSession(
|
||||||
completion(.success(()))
|
workspaceId: workspaceId,
|
||||||
|
docId: currentDocumentId
|
||||||
|
) { result in
|
||||||
|
switch result {
|
||||||
|
case let .success(session):
|
||||||
|
self.currentSession = session
|
||||||
|
completion(.success(()))
|
||||||
|
case let .failure(error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -45,7 +45,7 @@ private class _AttachmentManagementController: UIViewController {
|
|||||||
Section,
|
Section,
|
||||||
Item
|
Item
|
||||||
> = .init(tableView: tableView) { [weak self] tableView, indexPath, item in
|
> = .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.configure(with: item)
|
||||||
cell.onDelete = { [weak self] in
|
cell.onDelete = { [weak self] in
|
||||||
guard let delegateController = self?.delegateController else { return }
|
guard let delegateController = self?.delegateController else { return }
|
||||||
@@ -119,7 +119,7 @@ private class _AttachmentManagementController: UIViewController {
|
|||||||
|
|
||||||
tableView.backgroundColor = .clear
|
tableView.backgroundColor = .clear
|
||||||
tableView.separatorStyle = .none
|
tableView.separatorStyle = .none
|
||||||
tableView.register(AttachmentCell.self, forCellReuseIdentifier: "AttachmentCell")
|
tableView.register(AttachmentManagementCell.self, forCellReuseIdentifier: "AttachmentManagementCell")
|
||||||
tableView.clipsToBounds = true
|
tableView.clipsToBounds = true
|
||||||
tableView.rowHeight = UITableView.automaticDimension
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
view.addSubview(tableView)
|
view.addSubview(tableView)
|
||||||
@@ -156,7 +156,7 @@ private class _AttachmentManagementController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class AttachmentCell: UITableViewCell {
|
private class AttachmentManagementCell: UITableViewCell {
|
||||||
let container = UIView().then {
|
let container = UIView().then {
|
||||||
$0.layer.cornerRadius = 4
|
$0.layer.cornerRadius = 4
|
||||||
$0.layer.borderWidth = 0.5
|
$0.layer.borderWidth = 0.5
|
||||||
|
|||||||
+12
-33
@@ -76,41 +76,20 @@ extension MainViewController: InputBoxDelegate {
|
|||||||
|
|
||||||
func inputBoxDidSend(_ inputBox: InputBox) {
|
func inputBoxDidSend(_ inputBox: InputBox) {
|
||||||
let inputData = inputBox.inputBoxData
|
let inputData = inputBox.inputBoxData
|
||||||
|
inputBox.text = ""
|
||||||
|
inputBox.viewModel.clearAllAttachments()
|
||||||
|
|
||||||
Task { @MainActor in
|
guard let currentSession = IntelligentContext.shared.currentSession else {
|
||||||
do {
|
showAlert(title: "Error", message: "No active session available")
|
||||||
let chatManager = ChatManager.shared
|
return
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ChatManager.shared.closeAll()
|
||||||
|
ChatManager.shared.startUserRequest(
|
||||||
|
content: inputData.text,
|
||||||
|
inputBoxData: inputData,
|
||||||
|
sessionId: currentSession.id
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showAlert(title: String, message: String) {
|
private func showAlert(title: String, message: String) {
|
||||||
|
|||||||
+12
-113
@@ -10,25 +10,8 @@ class MainViewController: UIViewController {
|
|||||||
$0.delegate = self
|
$0.delegate = self
|
||||||
}
|
}
|
||||||
|
|
||||||
lazy var tableView = UITableView().then {
|
lazy var chatTableView = ChatTableView().then {
|
||||||
$0.backgroundColor = .clear
|
|
||||||
$0.separatorStyle = .none
|
|
||||||
$0.delegate = self
|
$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 {
|
lazy var inputBox = InputBox().then {
|
||||||
@@ -49,10 +32,9 @@ class MainViewController: UIViewController {
|
|||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
|
|
||||||
private var messages: [ChatMessage] = []
|
var cancellables = Set<AnyCancellable>()
|
||||||
private var cancellables = Set<AnyCancellable>()
|
let intelligentContext = IntelligentContext.shared
|
||||||
private let intelligentContext = IntelligentContext.shared
|
let chatManager = ChatManager.shared
|
||||||
private let chatManager = ChatManager.shared
|
|
||||||
var terminateEditGesture: UITapGestureRecognizer!
|
var terminateEditGesture: UITapGestureRecognizer!
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
@@ -62,7 +44,6 @@ class MainViewController: UIViewController {
|
|||||||
view.backgroundColor = .affineLayerBackgroundPrimary
|
view.backgroundColor = .affineLayerBackgroundPrimary
|
||||||
|
|
||||||
setupUI()
|
setupUI()
|
||||||
setupBindings()
|
|
||||||
|
|
||||||
view.isUserInteractionEnabled = true
|
view.isUserInteractionEnabled = true
|
||||||
terminateEditGesture = UITapGestureRecognizer(target: self, action: #selector(terminateEditing))
|
terminateEditGesture = UITapGestureRecognizer(target: self, action: #selector(terminateEditing))
|
||||||
@@ -73,32 +54,20 @@ class MainViewController: UIViewController {
|
|||||||
|
|
||||||
private func setupUI() {
|
private func setupUI() {
|
||||||
view.addSubview(headerView)
|
view.addSubview(headerView)
|
||||||
view.addSubview(tableView)
|
view.addSubview(chatTableView)
|
||||||
view.addSubview(emptyStateView)
|
|
||||||
view.addSubview(inputBox)
|
view.addSubview(inputBox)
|
||||||
view.addSubview(documentPickerHideDetector)
|
view.addSubview(documentPickerHideDetector)
|
||||||
view.addSubview(documentPickerView)
|
view.addSubview(documentPickerView)
|
||||||
|
|
||||||
emptyStateView.addSubview(emptyStateLabel)
|
|
||||||
|
|
||||||
headerView.snp.makeConstraints { make in
|
headerView.snp.makeConstraints { make in
|
||||||
make.top.equalTo(view.safeAreaLayoutGuide)
|
make.top.equalTo(view.safeAreaLayoutGuide)
|
||||||
make.leading.trailing.equalToSuperview()
|
make.leading.trailing.equalToSuperview()
|
||||||
}
|
}
|
||||||
|
|
||||||
tableView.snp.makeConstraints { make in
|
chatTableView.snp.makeConstraints { make in
|
||||||
make.top.equalTo(headerView.snp.bottom)
|
make.top.equalTo(headerView.snp.bottom)
|
||||||
make.leading.trailing.equalToSuperview()
|
make.left.right.equalToSuperview()
|
||||||
make.bottom.equalTo(inputBox.snp.top)
|
make.bottom.equalToSuperview()
|
||||||
}
|
|
||||||
|
|
||||||
emptyStateView.snp.makeConstraints { make in
|
|
||||||
make.center.equalTo(tableView)
|
|
||||||
make.width.lessThanOrEqualTo(tableView).inset(32)
|
|
||||||
}
|
|
||||||
|
|
||||||
emptyStateLabel.snp.makeConstraints { make in
|
|
||||||
make.edges.equalToSuperview()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
inputBox.snp.makeConstraints { make in
|
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) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
navigationController!.setNavigationBarHidden(true, animated: animated)
|
navigationController!.setNavigationBarHidden(true, animated: animated)
|
||||||
@@ -154,64 +105,12 @@ class MainViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Chat Methods
|
// 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 {
|
extension MainViewController: ChatTableViewDelegate {
|
||||||
func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
|
func chatTableView(_: ChatTableView, didSelectRowAt _: IndexPath) {
|
||||||
messages.count
|
// Handle cell selection if needed
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+139
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+112
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
-155
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+69
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+97
@@ -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: 实现重试逻辑
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
-23
@@ -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?
|
|
||||||
}
|
|
||||||
+1
-5
@@ -7,14 +7,10 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum CellType: String, Codable, CaseIterable {
|
public enum CellType: String, Codable, CaseIterable {
|
||||||
case userMessage
|
case userMessage
|
||||||
case assistantMessage
|
case assistantMessage
|
||||||
case systemMessage
|
case systemMessage
|
||||||
case attachment
|
|
||||||
case contextReference
|
|
||||||
case workflowStatus
|
|
||||||
case transcription
|
|
||||||
case loading
|
case loading
|
||||||
case error
|
case error
|
||||||
}
|
}
|
||||||
|
|||||||
+13
@@ -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 }
|
||||||
|
}
|
||||||
-41
@@ -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?
|
|
||||||
}
|
|
||||||
-15
@@ -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
|
|
||||||
}
|
|
||||||
+1
-3
@@ -9,8 +9,6 @@ import Foundation
|
|||||||
|
|
||||||
struct ErrorCellViewModel: ChatCellViewModel {
|
struct ErrorCellViewModel: ChatCellViewModel {
|
||||||
var cellType: CellType = .error
|
var cellType: CellType = .error
|
||||||
var id: String
|
var id: UUID
|
||||||
var errorMessage: String
|
var errorMessage: String
|
||||||
var canRetry: Bool
|
|
||||||
var retryAction: String?
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ import Foundation
|
|||||||
|
|
||||||
struct LoadingCellViewModel: ChatCellViewModel {
|
struct LoadingCellViewModel: ChatCellViewModel {
|
||||||
var cellType: CellType = .loading
|
var cellType: CellType = .loading
|
||||||
var id: String
|
var id: UUID
|
||||||
var message: String?
|
var message: String?
|
||||||
var progress: Double?
|
var progress: Double?
|
||||||
}
|
}
|
||||||
|
|||||||
+15
@@ -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?
|
||||||
|
}
|
||||||
+16
@@ -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] = []
|
||||||
|
}
|
||||||
-15
@@ -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
|
|
||||||
}
|
|
||||||
+154
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -85,4 +85,20 @@ extension InputBox: UITextViewDelegate {
|
|||||||
updatePlaceholderVisibility()
|
updatePlaceholderVisibility()
|
||||||
updateTextViewHeight()
|
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])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -30,6 +30,7 @@ class InputBox: UIView {
|
|||||||
$0.textContainerInset = .zero
|
$0.textContainerInset = .zero
|
||||||
$0.delegate = self
|
$0.delegate = self
|
||||||
$0.text = ""
|
$0.text = ""
|
||||||
|
$0.returnKeyType = .send
|
||||||
}
|
}
|
||||||
|
|
||||||
lazy var placeholderLabel = UILabel().then {
|
lazy var placeholderLabel = UILabel().then {
|
||||||
@@ -147,7 +148,7 @@ class InputBox: UIView {
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
viewModel.$isNetworkEnabled
|
viewModel.$isSearchEnabled
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
.sink { [weak self] enabled in
|
.sink { [weak self] enabled in
|
||||||
self?.functionBar.updateNetworkState(isEnabled: enabled)
|
self?.functionBar.updateNetworkState(isEnabled: enabled)
|
||||||
|
|||||||
+13
@@ -5,6 +5,16 @@ import UIKit
|
|||||||
private let unselectedColor: UIColor = .affineIconPrimary
|
private let unselectedColor: UIColor = .affineIconPrimary
|
||||||
private let selectedColor: UIColor = .affineIconActivated
|
private let selectedColor: UIColor = .affineIconActivated
|
||||||
|
|
||||||
|
private let configurableOptions: [ConfigurableOptions] = [
|
||||||
|
.networking,
|
||||||
|
.reasoning,
|
||||||
|
]
|
||||||
|
enum ConfigurableOptions {
|
||||||
|
case tool
|
||||||
|
case networking
|
||||||
|
case reasoning
|
||||||
|
}
|
||||||
|
|
||||||
class InputBoxFunctionBar: UIView {
|
class InputBoxFunctionBar: UIView {
|
||||||
weak var delegate: InputBoxFunctionBarDelegate?
|
weak var delegate: InputBoxFunctionBarDelegate?
|
||||||
|
|
||||||
@@ -23,6 +33,7 @@ class InputBoxFunctionBar: UIView {
|
|||||||
$0.tintColor = unselectedColor
|
$0.tintColor = unselectedColor
|
||||||
$0.imageView?.contentMode = .scaleAspectFit
|
$0.imageView?.contentMode = .scaleAspectFit
|
||||||
$0.addTarget(self, action: #selector(toolButtonTapped), for: .touchUpInside)
|
$0.addTarget(self, action: #selector(toolButtonTapped), for: .touchUpInside)
|
||||||
|
$0.isHidden = !configurableOptions.contains(.tool)
|
||||||
}
|
}
|
||||||
|
|
||||||
lazy var networkButton = UIButton(type: .system).then {
|
lazy var networkButton = UIButton(type: .system).then {
|
||||||
@@ -30,6 +41,7 @@ class InputBoxFunctionBar: UIView {
|
|||||||
$0.tintColor = unselectedColor
|
$0.tintColor = unselectedColor
|
||||||
$0.imageView?.contentMode = .scaleAspectFit
|
$0.imageView?.contentMode = .scaleAspectFit
|
||||||
$0.addTarget(self, action: #selector(networkButtonTapped), for: .touchUpInside)
|
$0.addTarget(self, action: #selector(networkButtonTapped), for: .touchUpInside)
|
||||||
|
$0.isHidden = !configurableOptions.contains(.networking)
|
||||||
}
|
}
|
||||||
|
|
||||||
lazy var deepThinkingButton = UIButton(type: .system).then {
|
lazy var deepThinkingButton = UIButton(type: .system).then {
|
||||||
@@ -37,6 +49,7 @@ class InputBoxFunctionBar: UIView {
|
|||||||
$0.tintColor = unselectedColor
|
$0.tintColor = unselectedColor
|
||||||
$0.imageView?.contentMode = .scaleAspectFit
|
$0.imageView?.contentMode = .scaleAspectFit
|
||||||
$0.addTarget(self, action: #selector(deepThinkingButtonTapped), for: .touchUpInside)
|
$0.addTarget(self, action: #selector(deepThinkingButtonTapped), for: .touchUpInside)
|
||||||
|
$0.isHidden = !configurableOptions.contains(.reasoning)
|
||||||
}
|
}
|
||||||
|
|
||||||
lazy var sendButton = UIButton(type: .system).then {
|
lazy var sendButton = UIButton(type: .system).then {
|
||||||
|
|||||||
+7
-7
@@ -16,16 +16,16 @@ public struct InputBoxData {
|
|||||||
public var fileAttachments: [FileAttachment] = []
|
public var fileAttachments: [FileAttachment] = []
|
||||||
public var documentAttachments: [DocumentAttachment] = []
|
public var documentAttachments: [DocumentAttachment] = []
|
||||||
public var isToolEnabled: Bool
|
public var isToolEnabled: Bool
|
||||||
public var isNetworkEnabled: Bool
|
public var isSearchEnabled: Bool
|
||||||
public var isDeepThinkingEnabled: 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.text = text
|
||||||
self.imageAttachments = imageAttachments
|
self.imageAttachments = imageAttachments
|
||||||
self.fileAttachments = fileAttachments
|
self.fileAttachments = fileAttachments
|
||||||
self.documentAttachments = documentAttachments
|
self.documentAttachments = documentAttachments
|
||||||
self.isToolEnabled = isToolEnabled
|
self.isToolEnabled = isToolEnabled
|
||||||
self.isNetworkEnabled = isNetworkEnabled
|
self.isSearchEnabled = isSearchEnabled
|
||||||
self.isDeepThinkingEnabled = isDeepThinkingEnabled
|
self.isDeepThinkingEnabled = isDeepThinkingEnabled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ public class InputBoxViewModel: ObservableObject {
|
|||||||
|
|
||||||
@Published public var inputText: String = ""
|
@Published public var inputText: String = ""
|
||||||
@Published public var isToolEnabled: Bool = false
|
@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 isDeepThinkingEnabled: Bool = false
|
||||||
@Published public var imageAttachments: [ImageAttachment] = []
|
@Published public var imageAttachments: [ImageAttachment] = []
|
||||||
@Published public var fileAttachments: [FileAttachment] = []
|
@Published public var fileAttachments: [FileAttachment] = []
|
||||||
@@ -90,7 +90,7 @@ public extension InputBoxViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func toggleNetwork() {
|
func toggleNetwork() {
|
||||||
isNetworkEnabled.toggle()
|
isSearchEnabled.toggle()
|
||||||
}
|
}
|
||||||
|
|
||||||
func toggleDeepThinking() {
|
func toggleDeepThinking() {
|
||||||
@@ -148,7 +148,7 @@ public extension InputBoxViewModel {
|
|||||||
fileAttachments: fileAttachments,
|
fileAttachments: fileAttachments,
|
||||||
documentAttachments: documentAttachments,
|
documentAttachments: documentAttachments,
|
||||||
isToolEnabled: isToolEnabled,
|
isToolEnabled: isToolEnabled,
|
||||||
isNetworkEnabled: isNetworkEnabled,
|
isSearchEnabled: isSearchEnabled,
|
||||||
isDeepThinkingEnabled: isDeepThinkingEnabled
|
isDeepThinkingEnabled: isDeepThinkingEnabled
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,7 @@ public extension InputBoxViewModel {
|
|||||||
fileAttachments.removeAll()
|
fileAttachments.removeAll()
|
||||||
documentAttachments.removeAll()
|
documentAttachments.removeAll()
|
||||||
isToolEnabled = false
|
isToolEnabled = false
|
||||||
isNetworkEnabled = false
|
isSearchEnabled = false
|
||||||
isDeepThinkingEnabled = false
|
isDeepThinkingEnabled = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user