mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-22 20:46:38 +08:00
chore: define view model (#12949)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Introduced a chat interface with message list, empty state view, and support for user, assistant, and system messages. * Added a chat manager for session and message handling, including session creation, message sending, and error management. * Implemented various chat cell types (attachments, context references, workflow status, loading, and error cells) with corresponding data models and view models. * Enabled asynchronous message sending from the input box with error alerts and automatic session creation. * Added workflow and context-related models for advanced chat features. * **Enhancements** * Improved UI responsiveness with table view updates and dynamic empty state handling. * Provided a method to clear all attachments in the input box. * **Bug Fixes / Style** * Refined code formatting, access control, and minor stylistic improvements across multiple files for consistency. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 77;
|
objectVersion = 56;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@@ -92,6 +92,8 @@
|
|||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
C45499AB2D140B5000E21978 /* NBStore */ = {
|
C45499AB2D140B5000E21978 /* NBStore */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
);
|
||||||
path = NBStore;
|
path = NBStore;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -340,13 +342,9 @@
|
|||||||
);
|
);
|
||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
);
|
);
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
);
|
);
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AFFiNE/Pods-AFFiNE-frameworks.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AFFiNE/Pods-AFFiNE-frameworks.sh\"\n";
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ extension AFFiNEViewController: IntelligentsButtonDelegate {
|
|||||||
IntelligentContext.shared.webView = webView!
|
IntelligentContext.shared.webView = webView!
|
||||||
button.beginProgress()
|
button.beginProgress()
|
||||||
|
|
||||||
IntelligentContext.shared.preparePresent() { result in
|
IntelligentContext.shared.preparePresent { result in
|
||||||
button.stopProgress()
|
button.stopProgress()
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let success):
|
case .success:
|
||||||
let controller = IntelligentsController()
|
let controller = IntelligentsController()
|
||||||
self.present(controller, animated: true)
|
self.present(controller, animated: true)
|
||||||
case .failure(let failure):
|
case let .failure(failure):
|
||||||
let alert = UIAlertController(
|
let alert = UIAlertController(
|
||||||
title: "Error",
|
title: "Error",
|
||||||
message: failure.localizedDescription,
|
message: failure.localizedDescription,
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ class AFFiNEViewController: CAPBridgeViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func webView(with frame: CGRect, configuration: WKWebViewConfiguration) -> WKWebView {
|
override func webView(with frame: CGRect, configuration: WKWebViewConfiguration) -> WKWebView {
|
||||||
return super.webView(with: frame, configuration: configuration)
|
super.webView(with: frame, configuration: configuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func capacitorDidLoad() {
|
override func capacitorDidLoad() {
|
||||||
let plugins: [CAPPlugin] = [
|
let plugins: [CAPPlugin] = [
|
||||||
@@ -43,6 +43,3 @@ class AFFiNEViewController: CAPBridgeViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ final class AppConfigManager {
|
|||||||
let affineVersion: String
|
let affineVersion: String
|
||||||
}
|
}
|
||||||
|
|
||||||
static var affineVersion: String? = nil
|
static var affineVersion: String?
|
||||||
|
|
||||||
static func getAffineVersion() -> String {
|
static func getAffineVersion() -> String {
|
||||||
if affineVersion == nil {
|
if affineVersion == nil {
|
||||||
|
|||||||
@@ -22,14 +22,14 @@ enum ApplicationBridgedWindowScript: String {
|
|||||||
|
|
||||||
var requiresAsyncContext: Bool {
|
var requiresAsyncContext: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .getCurrentDocContentInMarkdown, .createNewDocByMarkdownInCurrentWorkspace: return true
|
case .getCurrentDocContentInMarkdown, .createNewDocByMarkdownInCurrentWorkspace: true
|
||||||
default: return false
|
default: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension WKWebView {
|
extension WKWebView {
|
||||||
func evaluateScript(_ script: ApplicationBridgedWindowScript, callback: @escaping (Any?) -> ()) {
|
func evaluateScript(_ script: ApplicationBridgedWindowScript, callback: @escaping (Any?) -> Void) {
|
||||||
if script.requiresAsyncContext {
|
if script.requiresAsyncContext {
|
||||||
callAsyncJavaScript(
|
callAsyncJavaScript(
|
||||||
script.rawValue,
|
script.rawValue,
|
||||||
@@ -38,7 +38,7 @@ extension WKWebView {
|
|||||||
in: .page
|
in: .page
|
||||||
) { result in
|
) { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let input):
|
case let .success(input):
|
||||||
callback(input)
|
callback(input)
|
||||||
case .failure:
|
case .failure:
|
||||||
callback(nil)
|
callback(nil)
|
||||||
@@ -49,5 +49,3 @@ extension WKWebView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,44 +10,44 @@ enum RequestParamError: Error {
|
|||||||
case request(key: String)
|
case request(key: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
extension JSValueContainer {
|
public extension JSValueContainer {
|
||||||
public func getStringEnsure(_ key: String) throws -> String {
|
func getStringEnsure(_ key: String) throws -> String {
|
||||||
guard let str = self.getString(key) else {
|
guard let str = getString(key) else {
|
||||||
throw RequestParamError.request(key: key)
|
throw RequestParamError.request(key: key)
|
||||||
}
|
}
|
||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
public func getIntEnsure(_ key: String) throws -> Int {
|
func getIntEnsure(_ key: String) throws -> Int {
|
||||||
guard let int = self.getInt(key) else {
|
guard let int = getInt(key) else {
|
||||||
throw RequestParamError.request(key: key)
|
throw RequestParamError.request(key: key)
|
||||||
}
|
}
|
||||||
return int
|
return int
|
||||||
}
|
}
|
||||||
|
|
||||||
public func getDoubleEnsure(_ key: String) throws -> Double {
|
func getDoubleEnsure(_ key: String) throws -> Double {
|
||||||
guard let doub = self.getDouble(key) else {
|
guard let doub = getDouble(key) else {
|
||||||
throw RequestParamError.request(key: key)
|
throw RequestParamError.request(key: key)
|
||||||
}
|
}
|
||||||
return doub
|
return doub
|
||||||
}
|
}
|
||||||
|
|
||||||
public func getBoolEnsure(_ key: String) throws -> Bool {
|
func getBoolEnsure(_ key: String) throws -> Bool {
|
||||||
guard let bool = self.getBool(key) else {
|
guard let bool = getBool(key) else {
|
||||||
throw RequestParamError.request(key: key)
|
throw RequestParamError.request(key: key)
|
||||||
}
|
}
|
||||||
return bool
|
return bool
|
||||||
}
|
}
|
||||||
|
|
||||||
public func getArrayEnsure(_ key: String) throws -> JSArray {
|
func getArrayEnsure(_ key: String) throws -> JSArray {
|
||||||
guard let arr = self.getArray(key) else {
|
guard let arr = getArray(key) else {
|
||||||
throw RequestParamError.request(key: key)
|
throw RequestParamError.request(key: key)
|
||||||
}
|
}
|
||||||
return arr
|
return arr
|
||||||
}
|
}
|
||||||
|
|
||||||
public func getArrayEnsure<T>(_ key: String, _ ofType: T.Type) throws -> [T] {
|
func getArrayEnsure<T>(_ key: String, _ ofType: T.Type) throws -> [T] {
|
||||||
guard let arr = self.getArray(key, ofType) else {
|
guard let arr = getArray(key, ofType) else {
|
||||||
throw RequestParamError.request(key: key)
|
throw RequestParamError.request(key: key)
|
||||||
}
|
}
|
||||||
return arr
|
return arr
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
final class Mutex<Wrapped>: @unchecked Sendable {
|
final class Mutex<Wrapped>: @unchecked Sendable {
|
||||||
private let lock = NSLock.init()
|
private let lock = NSLock()
|
||||||
private var wrapped: Wrapped
|
private var wrapped: Wrapped
|
||||||
|
|
||||||
init(_ wrapped: Wrapped) {
|
init(_ wrapped: Wrapped) {
|
||||||
@@ -16,7 +16,7 @@ final class Mutex<Wrapped>: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func withLock<R>(_ body: @Sendable (inout Wrapped) throws -> R) rethrows -> R {
|
func withLock<R>(_ body: @Sendable (inout Wrapped) throws -> R) rethrows -> R {
|
||||||
self.lock.lock()
|
lock.lock()
|
||||||
defer { self.lock.unlock() }
|
defer { self.lock.unlock() }
|
||||||
return try body(&wrapped)
|
return try body(&wrapped)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
|
|
||||||
let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/auth/sign-in", headers: [
|
let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/auth/sign-in", headers: [
|
||||||
"x-captcha-token": verifyToken,
|
"x-captcha-token": verifyToken,
|
||||||
"x-captcha-challenge": challenge
|
"x-captcha-challenge": challenge,
|
||||||
], body: ["email": email, "password": password])
|
], body: ["email": email, "password": password])
|
||||||
|
|
||||||
if response.statusCode >= 400 {
|
if response.statusCode >= 400 {
|
||||||
@@ -120,7 +120,6 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
call.resolve(["ok": true])
|
call.resolve(["ok": true])
|
||||||
} catch {
|
} catch {
|
||||||
call.reject("Failed to sign in, \(error)", nil, error)
|
call.reject("Failed to sign in, \(error)", nil, error)
|
||||||
@@ -142,13 +141,13 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetch(_ endpoint: String, method: String, action: String, headers: Dictionary<String, String?>, body: Encodable?) async throws -> (Data, HTTPURLResponse) {
|
private func fetch(_ endpoint: String, method: String, action: String, headers: [String: String?], body: Encodable?) async throws -> (Data, HTTPURLResponse) {
|
||||||
guard let targetUrl = URL(string: "\(endpoint)\(action)") else {
|
guard let targetUrl = URL(string: "\(endpoint)\(action)") else {
|
||||||
throw AuthError.invalidEndpoint
|
throw AuthError.invalidEndpoint
|
||||||
}
|
}
|
||||||
|
|
||||||
var request = URLRequest(url: targetUrl);
|
var request = URLRequest(url: targetUrl)
|
||||||
request.httpMethod = method;
|
request.httpMethod = method
|
||||||
request.httpShouldHandleCookies = true
|
request.httpShouldHandleCookies = true
|
||||||
for (key, value) in headers {
|
for (key, value) in headers {
|
||||||
request.setValue(value, forHTTPHeaderField: key)
|
request.setValue(value, forHTTPHeaderField: key)
|
||||||
@@ -160,7 +159,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
request.setValue(AppConfigManager.getAffineVersion(), forHTTPHeaderField: "x-affine-version")
|
request.setValue(AppConfigManager.getAffineVersion(), forHTTPHeaderField: "x-affine-version")
|
||||||
request.timeoutInterval = 10 // time out 10s
|
request.timeoutInterval = 10 // time out 10s
|
||||||
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: request);
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
guard let httpResponse = response as? HTTPURLResponse else {
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
throw AuthError.internalError
|
throw AuthError.internalError
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import Capacitor
|
import Capacitor
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
//@objc(IntelligentsPlugin)
|
// @objc(IntelligentsPlugin)
|
||||||
//public class IntelligentsPlugin: CAPPlugin, CAPBridgedPlugin {
|
// public class IntelligentsPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||||
// public let identifier = "IntelligentsPlugin"
|
// public let identifier = "IntelligentsPlugin"
|
||||||
// public let jsName = "Intelligents"
|
// public let jsName = "Intelligents"
|
||||||
// public let pluginMethods: [CAPPluginMethod] = [
|
// public let pluginMethods: [CAPPluginMethod] = [
|
||||||
@@ -33,4 +33,4 @@ import Foundation
|
|||||||
// call.resolve()
|
// call.resolve()
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
//}
|
// }
|
||||||
|
|||||||
@@ -52,10 +52,10 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
let peerDir = documentDir.appending(path: "workspaces")
|
let peerDir = documentDir.appending(path: "workspaces")
|
||||||
.appending(path: spaceType)
|
.appending(path: spaceType)
|
||||||
.appending(path:
|
.appending(path:
|
||||||
peer
|
peer
|
||||||
.replacing(#/[\/!@#$%^&*()+~`"':;,?<>|]/#, with: "_")
|
.replacing(#/[\/!@#$%^&*()+~`"':;,?<>|]/#, with: "_")
|
||||||
.replacing(/_+/, with: "_")
|
.replacing(/_+/, with: "_")
|
||||||
.replacing(/_+$/, with: ""))
|
.replacing(/_+$/, with: ""))
|
||||||
try FileManager.default.createDirectory(atPath: peerDir.path(), withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(atPath: peerDir.path(), withIntermediateDirectories: true)
|
||||||
let db = peerDir.appending(path: spaceId + ".db")
|
let db = peerDir.appending(path: spaceId + ".db")
|
||||||
try await docStoragePool.connect(universalId: id, path: db.path())
|
try await docStoragePool.connect(universalId: id, path: db.path())
|
||||||
@@ -242,7 +242,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
"data": blob.data,
|
"data": blob.data,
|
||||||
"mime": blob.mime,
|
"mime": blob.mime,
|
||||||
"size": blob.size,
|
"size": blob.size,
|
||||||
"createdAt": blob.createdAt
|
"createdAt": blob.createdAt,
|
||||||
])
|
])
|
||||||
} else {
|
} else {
|
||||||
call.resolve()
|
call.resolve()
|
||||||
@@ -506,7 +506,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
)
|
)
|
||||||
|
|
||||||
call.resolve([
|
call.resolve([
|
||||||
"uploadedAt": uploadedAt as Any
|
"uploadedAt": uploadedAt as Any,
|
||||||
])
|
])
|
||||||
} catch {
|
} catch {
|
||||||
call.reject("Failed to get blob uploaded, \(error)", nil, error)
|
call.reject("Failed to get blob uploaded, \(error)", nil, error)
|
||||||
|
|||||||
@@ -11,26 +11,24 @@ class SafeWKURLSchemeTask: WKURLSchemeTask, NSObject {
|
|||||||
var origin: any WKURLSchemeTask
|
var origin: any WKURLSchemeTask
|
||||||
init(origin: any WKURLSchemeTask) {
|
init(origin: any WKURLSchemeTask) {
|
||||||
self.origin = origin
|
self.origin = origin
|
||||||
self.request = origin.request
|
request = origin.request
|
||||||
}
|
}
|
||||||
|
|
||||||
var request: URLRequest
|
var request: URLRequest
|
||||||
|
|
||||||
func didReceive(_ response: URLResponse) {
|
func didReceive(_: URLResponse) {
|
||||||
<#code#>
|
<#code#>
|
||||||
}
|
}
|
||||||
|
|
||||||
func didReceive(_ data: Data) {
|
func didReceive(_: Data) {
|
||||||
self.origin.didReceive(<#T##response: URLResponse##URLResponse#>)
|
origin.didReceive(<#T##response: URLResponse##URLResponse#>)
|
||||||
}
|
}
|
||||||
|
|
||||||
func didFinish() {
|
func didFinish() {
|
||||||
self.origin.didFinish()
|
origin.didFinish()
|
||||||
}
|
}
|
||||||
|
|
||||||
func didFailWithError(_ error: any Error) {
|
func didFailWithError(_ error: any Error) {
|
||||||
self.origin.didFailWithError(error)
|
origin.didFailWithError(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
-1
@@ -39,4 +39,3 @@ public enum CustomJSON: CustomScalarType, Hashable {
|
|||||||
hasher.combine(_jsonValue)
|
hasher.combine(_jsonValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
//
|
||||||
|
// ChatManager+ContextModels.swift
|
||||||
|
// Intelligents
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 6/26/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AffineGraphQL
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - ChatManager Context Models Extension
|
||||||
|
|
||||||
|
extension ChatManager {
|
||||||
|
// MARK: - Context Models
|
||||||
|
|
||||||
|
struct ContextReference: Codable, Identifiable, Equatable, Hashable {
|
||||||
|
var id: String
|
||||||
|
var fileId: String?
|
||||||
|
var docId: String?
|
||||||
|
var chunk: Int
|
||||||
|
var content: String
|
||||||
|
var distance: Double
|
||||||
|
var highlightedContent: String?
|
||||||
|
|
||||||
|
init(fileId: String? = nil, docId: String? = nil, chunk: Int, content: String, distance: Double, highlightedContent: String? = nil) {
|
||||||
|
id = UUID().uuidString
|
||||||
|
self.fileId = fileId
|
||||||
|
self.docId = docId
|
||||||
|
self.chunk = chunk
|
||||||
|
self.content = content
|
||||||
|
self.distance = distance
|
||||||
|
self.highlightedContent = highlightedContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CopilotContext: Codable, Identifiable, Equatable, Hashable {
|
||||||
|
var id: String
|
||||||
|
var sessionId: String
|
||||||
|
var workspaceId: String
|
||||||
|
var files: [ContextFile]
|
||||||
|
var docs: [ContextDoc]
|
||||||
|
var categories: [ContextCategory]
|
||||||
|
|
||||||
|
init(id: String, sessionId: String, workspaceId: String, files: [ContextFile] = [], docs: [ContextDoc] = [], categories: [ContextCategory] = []) {
|
||||||
|
self.id = id
|
||||||
|
self.sessionId = sessionId
|
||||||
|
self.workspaceId = workspaceId
|
||||||
|
self.files = files
|
||||||
|
self.docs = docs
|
||||||
|
self.categories = categories
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ContextFile: Codable, Identifiable, Equatable, Hashable {
|
||||||
|
var id: String
|
||||||
|
var contextId: String
|
||||||
|
var blobId: String
|
||||||
|
var fileName: String?
|
||||||
|
var fileSize: Int?
|
||||||
|
var mimeType: String?
|
||||||
|
var embeddingStatus: ContextEmbedStatus?
|
||||||
|
var createdAt: DateTime?
|
||||||
|
|
||||||
|
var createdDate: Date? {
|
||||||
|
createdAt?.decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ContextDoc: Codable, Identifiable, Equatable, Hashable {
|
||||||
|
var id: String
|
||||||
|
var contextId: String
|
||||||
|
var docId: String
|
||||||
|
var title: String?
|
||||||
|
var embeddingStatus: ContextEmbedStatus?
|
||||||
|
var createdAt: DateTime?
|
||||||
|
|
||||||
|
var createdDate: Date? {
|
||||||
|
createdAt?.decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ContextCategory: Codable, Identifiable, Equatable, Hashable {
|
||||||
|
var id: String
|
||||||
|
var contextId: String
|
||||||
|
var type: ContextCategoryType
|
||||||
|
var docs: [String]
|
||||||
|
var name: String?
|
||||||
|
var createdAt: DateTime?
|
||||||
|
|
||||||
|
var createdDate: Date? {
|
||||||
|
createdAt?.decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ContextEmbedStatus: String, Codable, CaseIterable {
|
||||||
|
case pending = "Pending"
|
||||||
|
case failed = "Failed"
|
||||||
|
case completed = "Completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ContextCategoryType: String, Codable, CaseIterable {
|
||||||
|
case tag = "TAG"
|
||||||
|
case collection = "COLLECTION"
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MatchContextResult: Codable, Identifiable, Equatable, Hashable {
|
||||||
|
var id: String
|
||||||
|
var fileId: String?
|
||||||
|
var docId: String?
|
||||||
|
var chunk: Int
|
||||||
|
var content: String
|
||||||
|
var distance: Double
|
||||||
|
var highlightedContent: String?
|
||||||
|
|
||||||
|
init(fileId: String? = nil, docId: String? = nil, chunk: Int, content: String, distance: Double, highlightedContent: String? = nil) {
|
||||||
|
id = UUID().uuidString
|
||||||
|
self.fileId = fileId
|
||||||
|
self.docId = docId
|
||||||
|
self.chunk = chunk
|
||||||
|
self.content = content
|
||||||
|
self.distance = distance
|
||||||
|
self.highlightedContent = highlightedContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
//
|
||||||
|
// ChatManager+InputModels.swift
|
||||||
|
// Intelligents
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 6/26/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - ChatManager Input Models Extension
|
||||||
|
|
||||||
|
extension ChatManager {
|
||||||
|
// MARK: - Input Models
|
||||||
|
|
||||||
|
struct AddContextFileInput: Codable, Equatable, Hashable {
|
||||||
|
var contextId: String
|
||||||
|
var blobId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RemoveContextFileInput: Codable, Equatable, Hashable {
|
||||||
|
var contextId: String
|
||||||
|
var fileId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AddContextDocInput: Codable, Equatable, Hashable {
|
||||||
|
var contextId: String
|
||||||
|
var docId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RemoveContextDocInput: Codable, Equatable, Hashable {
|
||||||
|
var contextId: String
|
||||||
|
var docId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AddContextCategoryInput: Codable, Equatable, Hashable {
|
||||||
|
var contextId: String
|
||||||
|
var docs: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RemoveContextCategoryInput: Codable, Equatable, Hashable {
|
||||||
|
var contextId: String
|
||||||
|
var categoryId: String
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
//
|
||||||
|
// ChatManager+WorkflowModels.swift
|
||||||
|
// Intelligents
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 6/26/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AffineGraphQL
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - ChatManager Workflow Models Extension
|
||||||
|
|
||||||
|
extension ChatManager {
|
||||||
|
// MARK: - Workflow Models
|
||||||
|
|
||||||
|
struct WorkflowEventData: Codable, Identifiable, Equatable, Hashable {
|
||||||
|
var id: String
|
||||||
|
var status: String
|
||||||
|
var type: String
|
||||||
|
var progress: Double?
|
||||||
|
var message: String?
|
||||||
|
|
||||||
|
init(status: String, type: String, progress: Double? = nil, message: String? = nil) {
|
||||||
|
id = UUID().uuidString
|
||||||
|
self.status = status
|
||||||
|
self.type = type
|
||||||
|
self.progress = progress
|
||||||
|
self.message = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WorkspaceEmbeddingStatus: Codable, Identifiable, Equatable, Hashable {
|
||||||
|
var id: String
|
||||||
|
var workspaceId: String
|
||||||
|
var total: Int
|
||||||
|
var embedded: Int
|
||||||
|
|
||||||
|
var progress: Double {
|
||||||
|
total > 0 ? Double(embedded) / Double(total) : 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
init(workspaceId: String, total: Int, embedded: Int) {
|
||||||
|
id = workspaceId
|
||||||
|
self.workspaceId = workspaceId
|
||||||
|
self.total = total
|
||||||
|
self.embedded = embedded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChatEvent: Codable, Identifiable, Equatable, Hashable {
|
||||||
|
var id: String
|
||||||
|
var type: ChatEventType
|
||||||
|
var data: String
|
||||||
|
var timestamp: DateTime?
|
||||||
|
|
||||||
|
var timestampDate: Date? {
|
||||||
|
timestamp?.decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
init(type: ChatEventType, data: String, timestamp: DateTime? = nil) {
|
||||||
|
id = UUID().uuidString
|
||||||
|
self.type = type
|
||||||
|
self.data = data
|
||||||
|
self.timestamp = timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ChatEventType: String, Codable, CaseIterable {
|
||||||
|
case message
|
||||||
|
case attachment
|
||||||
|
case event
|
||||||
|
case ping
|
||||||
|
}
|
||||||
|
}
|
||||||
+227
@@ -0,0 +1,227 @@
|
|||||||
|
//
|
||||||
|
// ChatManager.swift
|
||||||
|
// Intelligents
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 6/26/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AffineGraphQL
|
||||||
|
import Apollo
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - ChatManager
|
||||||
|
|
||||||
|
public class ChatManager: ObservableObject {
|
||||||
|
public static let shared = ChatManager()
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
@Published public private(set) var sessions: [SessionViewModel] = []
|
||||||
|
@Published public private(set) var currentSession: SessionViewModel?
|
||||||
|
@Published public private(set) var messages: [String: [ChatMessage]] = [:]
|
||||||
|
@Published public private(set) var isLoading = false
|
||||||
|
@Published public private(set) var error: Error?
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private let apolloClient: ApolloClient
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
private init(apolloClient: ApolloClient = QLService.shared.client) {
|
||||||
|
self.apolloClient = apolloClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
|
||||||
|
public func createSession(
|
||||||
|
workspaceId: String,
|
||||||
|
promptName: String = "",
|
||||||
|
docId: String? = nil,
|
||||||
|
pinned: Bool = false
|
||||||
|
) async throws -> SessionViewModel {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
let input = CreateChatSessionInput(
|
||||||
|
docId: docId.map { .some($0) } ?? .null,
|
||||||
|
pinned: .some(pinned),
|
||||||
|
promptName: promptName,
|
||||||
|
workspaceId: workspaceId
|
||||||
|
)
|
||||||
|
|
||||||
|
let mutation = CreateCopilotSessionMutation(options: input)
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
apolloClient.perform(mutation: mutation) { result in
|
||||||
|
switch result {
|
||||||
|
case let .success(graphQLResult):
|
||||||
|
guard let sessionId = graphQLResult.data?.createCopilotSession else {
|
||||||
|
continuation.resume(throwing: ChatError.invalidResponse)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = SessionViewModel(
|
||||||
|
id: sessionId,
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
docId: docId,
|
||||||
|
promptName: promptName,
|
||||||
|
model: nil,
|
||||||
|
pinned: pinned,
|
||||||
|
tokens: 0,
|
||||||
|
createdAt: DateTime(date: Date()),
|
||||||
|
updatedAt: DateTime(date: Date()),
|
||||||
|
parentSessionId: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
self.sessions.append(session)
|
||||||
|
self.currentSession = session
|
||||||
|
self.messages[sessionId] = []
|
||||||
|
self.isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.resume(returning: session)
|
||||||
|
|
||||||
|
case let .failure(error):
|
||||||
|
Task { @MainActor in
|
||||||
|
self.error = error
|
||||||
|
self.isLoading = false
|
||||||
|
}
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
self.error = error
|
||||||
|
self.isLoading = false
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func sendMessage(
|
||||||
|
content: String,
|
||||||
|
attachments: [String] = [],
|
||||||
|
sessionId: String? = nil
|
||||||
|
) async throws {
|
||||||
|
guard let targetSessionId = sessionId ?? currentSession?.id else {
|
||||||
|
throw ChatError.noActiveSession
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
// Add user message immediately
|
||||||
|
let userMessage = ChatMessage(
|
||||||
|
id: UUID().uuidString,
|
||||||
|
role: .user,
|
||||||
|
content: content,
|
||||||
|
attachments: attachments.isEmpty ? nil : attachments,
|
||||||
|
params: nil,
|
||||||
|
createdAt: DateTime(date: Date())
|
||||||
|
)
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
var sessionMessages = self.messages[targetSessionId] ?? []
|
||||||
|
sessionMessages.append(userMessage)
|
||||||
|
self.messages[targetSessionId] = sessionMessages
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let input = CreateChatMessageInput(
|
||||||
|
attachments: attachments.isEmpty ? .null : .some(attachments),
|
||||||
|
blobs: .null,
|
||||||
|
content: .some(content),
|
||||||
|
params: .null,
|
||||||
|
sessionId: targetSessionId
|
||||||
|
)
|
||||||
|
|
||||||
|
let mutation = CreateCopilotMessageMutation(options: input)
|
||||||
|
|
||||||
|
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||||
|
apolloClient.perform(mutation: mutation) { result in
|
||||||
|
switch result {
|
||||||
|
case let .success(graphQLResult):
|
||||||
|
guard let messageId = graphQLResult.data?.createCopilotMessage else {
|
||||||
|
continuation.resume(throwing: ChatError.invalidResponse)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add assistant message placeholder
|
||||||
|
let assistantMessage = ChatMessage(
|
||||||
|
id: messageId,
|
||||||
|
role: .assistant,
|
||||||
|
content: "Thinking...",
|
||||||
|
attachments: nil,
|
||||||
|
params: nil,
|
||||||
|
createdAt: DateTime(date: Date())
|
||||||
|
)
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
var sessionMessages = self.messages[targetSessionId] ?? []
|
||||||
|
sessionMessages.append(assistantMessage)
|
||||||
|
self.messages[targetSessionId] = sessionMessages
|
||||||
|
self.isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.resume()
|
||||||
|
|
||||||
|
// TODO: Implement streaming response handling
|
||||||
|
|
||||||
|
case let .failure(error):
|
||||||
|
Task { @MainActor in
|
||||||
|
self.error = error
|
||||||
|
self.isLoading = false
|
||||||
|
}
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
self.error = error
|
||||||
|
self.isLoading = false
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func switchToSession(_ session: SessionViewModel) {
|
||||||
|
currentSession = session
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteSession(sessionId: String) {
|
||||||
|
sessions.removeAll { $0.id == sessionId }
|
||||||
|
messages.removeValue(forKey: sessionId)
|
||||||
|
|
||||||
|
if currentSession?.id == sessionId {
|
||||||
|
currentSession = sessions.first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func clearError() {
|
||||||
|
error = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ChatError
|
||||||
|
|
||||||
|
public enum ChatError: LocalizedError {
|
||||||
|
case noActiveSession
|
||||||
|
case invalidResponse
|
||||||
|
case networkError(Error)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .noActiveSession:
|
||||||
|
"No active chat session"
|
||||||
|
case .invalidResponse:
|
||||||
|
"Invalid response from server"
|
||||||
|
case let .networkError(error):
|
||||||
|
"Network error: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
//
|
||||||
|
// ChatMessage.swift
|
||||||
|
// Intelligents
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 6/26/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AffineGraphQL
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct ChatMessage: Codable, Identifiable, Equatable, Hashable {
|
||||||
|
public var id: String?
|
||||||
|
public var role: MessageRole
|
||||||
|
public var content: String
|
||||||
|
public var attachments: [String]?
|
||||||
|
public var params: [String: String]?
|
||||||
|
public var createdAt: DateTime?
|
||||||
|
|
||||||
|
public var createdDate: Date? {
|
||||||
|
createdAt?.decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
public var messageId: String {
|
||||||
|
id ?? UUID().uuidString
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String? = nil,
|
||||||
|
role: MessageRole,
|
||||||
|
content: String,
|
||||||
|
attachments: [String]? = nil,
|
||||||
|
params: [String: String]? = nil,
|
||||||
|
createdAt: DateTime? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.role = role
|
||||||
|
self.content = content
|
||||||
|
self.attachments = attachments
|
||||||
|
self.params = params
|
||||||
|
self.createdAt = createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension ChatMessage {
|
||||||
|
enum MessageRole: String, Codable, CaseIterable {
|
||||||
|
case user
|
||||||
|
case assistant
|
||||||
|
case system
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SessionViewModel: Codable, Identifiable, Equatable, Hashable {
|
||||||
|
public var id: String
|
||||||
|
public var workspaceId: String
|
||||||
|
public var docId: String?
|
||||||
|
public var promptName: String
|
||||||
|
public var model: String?
|
||||||
|
public var pinned: Bool
|
||||||
|
public var tokens: Int
|
||||||
|
public var createdAt: DateTime?
|
||||||
|
public var updatedAt: DateTime?
|
||||||
|
public var parentSessionId: String?
|
||||||
|
|
||||||
|
public var createdDate: Date? {
|
||||||
|
createdAt?.decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
public var updatedDate: Date? {
|
||||||
|
updatedAt?.decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
workspaceId: String,
|
||||||
|
docId: String? = nil,
|
||||||
|
promptName: String,
|
||||||
|
model: String? = nil,
|
||||||
|
pinned: Bool,
|
||||||
|
tokens: Int,
|
||||||
|
createdAt: DateTime? = nil,
|
||||||
|
updatedAt: DateTime? = nil,
|
||||||
|
parentSessionId: String? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.workspaceId = workspaceId
|
||||||
|
self.docId = docId
|
||||||
|
self.promptName = promptName
|
||||||
|
self.model = model
|
||||||
|
self.pinned = pinned
|
||||||
|
self.tokens = tokens
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.updatedAt = updatedAt
|
||||||
|
self.parentSessionId = parentSessionId
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-3
@@ -11,7 +11,6 @@ import ApolloAPI
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
|
/// A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
|
||||||
extension DateTime {
|
|
||||||
extension DateTime {
|
extension DateTime {
|
||||||
private static let formatter: DateFormatter = {
|
private static let formatter: DateFormatter = {
|
||||||
let fmt = DateFormatter()
|
let fmt = DateFormatter()
|
||||||
@@ -20,8 +19,11 @@ extension DateTime {
|
|||||||
return fmt
|
return fmt
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
init(date: Date) {
|
||||||
|
self.init(Self.formatter.string(from: date))
|
||||||
|
}
|
||||||
|
|
||||||
var decoded: Date? {
|
var decoded: Date? {
|
||||||
return Self.formatter.date(from: self)
|
Self.formatter.date(from: self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ import Foundation
|
|||||||
public final class QLService {
|
public final class QLService {
|
||||||
public static let shared = QLService()
|
public static let shared = QLService()
|
||||||
private var endpointURL: URL
|
private var endpointURL: URL
|
||||||
public private(set) var client: ApolloClient
|
public var client: ApolloClient
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
let store = ApolloStore()
|
let store = ApolloStore()
|
||||||
|
|||||||
+42
-1
@@ -75,7 +75,48 @@ extension MainViewController: InputBoxDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func inputBoxDidSend(_ inputBox: InputBox) {
|
func inputBoxDidSend(_ inputBox: InputBox) {
|
||||||
print(#function, inputBox, inputBox.viewModel)
|
let inputData = inputBox.inputBoxData
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
do {
|
||||||
|
let chatManager = ChatManager.shared
|
||||||
|
|
||||||
|
if let currentSession = chatManager.currentSession {
|
||||||
|
try await chatManager.sendMessage(
|
||||||
|
content: inputData.text,
|
||||||
|
attachments: [], // TODO: Handle attachments
|
||||||
|
sessionId: currentSession.id
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
guard let workspaceId = IntelligentContext.shared.webViewMetadata[.currentWorkspaceId] as? String,
|
||||||
|
!workspaceId.isEmpty
|
||||||
|
else {
|
||||||
|
showAlert(title: "Error", message: "No workspace available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = try await chatManager.createSession(workspaceId: workspaceId)
|
||||||
|
|
||||||
|
try await chatManager.sendMessage(
|
||||||
|
content: inputData.text,
|
||||||
|
attachments: [], // TODO: Handle attachments
|
||||||
|
sessionId: session.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
inputBox.text = ""
|
||||||
|
inputBox.viewModel.clearAllAttachments()
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
showAlert(title: "Error", message: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showAlert(title: String, message: String) {
|
||||||
|
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
||||||
|
present(alert, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func inputBoxTextDidChange(_ text: String) {
|
func inputBoxTextDidChange(_ text: String) {
|
||||||
|
|||||||
+131
-7
@@ -10,6 +10,27 @@ class MainViewController: UIViewController {
|
|||||||
$0.delegate = self
|
$0.delegate = self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lazy var tableView = UITableView().then {
|
||||||
|
$0.backgroundColor = .clear
|
||||||
|
$0.separatorStyle = .none
|
||||||
|
$0.delegate = self
|
||||||
|
$0.dataSource = self
|
||||||
|
$0.register(ChatCell.self, forCellReuseIdentifier: "ChatCell")
|
||||||
|
$0.keyboardDismissMode = .interactive
|
||||||
|
$0.contentInsetAdjustmentBehavior = .never
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy var emptyStateView = UIView().then {
|
||||||
|
$0.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy var emptyStateLabel = UILabel().then {
|
||||||
|
$0.text = "Start a conversation..."
|
||||||
|
$0.font = .systemFont(ofSize: 18, weight: .medium)
|
||||||
|
$0.textColor = .systemGray
|
||||||
|
$0.textAlignment = .center
|
||||||
|
}
|
||||||
|
|
||||||
lazy var inputBox = InputBox().then {
|
lazy var inputBox = InputBox().then {
|
||||||
$0.delegate = self
|
$0.delegate = self
|
||||||
}
|
}
|
||||||
@@ -28,8 +49,10 @@ class MainViewController: UIViewController {
|
|||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
|
|
||||||
|
private var messages: [ChatMessage] = []
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private let intelligentContext = IntelligentContext.shared
|
private let intelligentContext = IntelligentContext.shared
|
||||||
|
private let chatManager = ChatManager.shared
|
||||||
var terminateEditGesture: UITapGestureRecognizer!
|
var terminateEditGesture: UITapGestureRecognizer!
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
@@ -38,21 +61,46 @@ class MainViewController: UIViewController {
|
|||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = .affineLayerBackgroundPrimary
|
view.backgroundColor = .affineLayerBackgroundPrimary
|
||||||
|
|
||||||
let inputBox = InputBox().then {
|
setupUI()
|
||||||
$0.delegate = self
|
setupBindings()
|
||||||
}
|
|
||||||
self.inputBox = inputBox
|
|
||||||
|
|
||||||
|
view.isUserInteractionEnabled = true
|
||||||
|
terminateEditGesture = UITapGestureRecognizer(target: self, action: #selector(terminateEditing))
|
||||||
|
view.addGestureRecognizer(terminateEditGesture)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Setup
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
view.addSubview(headerView)
|
view.addSubview(headerView)
|
||||||
|
view.addSubview(tableView)
|
||||||
|
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
|
||||||
|
make.top.equalTo(headerView.snp.bottom)
|
||||||
|
make.leading.trailing.equalToSuperview()
|
||||||
|
make.bottom.equalTo(inputBox.snp.top)
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyStateView.snp.makeConstraints { make in
|
||||||
|
make.center.equalTo(tableView)
|
||||||
|
make.width.lessThanOrEqualTo(tableView).inset(32)
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyStateLabel.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
inputBox.snp.makeConstraints { make in
|
inputBox.snp.makeConstraints { make in
|
||||||
make.leading.trailing.equalToSuperview()
|
make.leading.trailing.equalToSuperview()
|
||||||
make.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
|
make.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
|
||||||
@@ -67,10 +115,24 @@ class MainViewController: UIViewController {
|
|||||||
make.leading.trailing.equalToSuperview()
|
make.leading.trailing.equalToSuperview()
|
||||||
make.height.equalTo(500)
|
make.height.equalTo(500)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
view.isUserInteractionEnabled = true
|
private func setupBindings() {
|
||||||
terminateEditGesture = UITapGestureRecognizer(target: self, action: #selector(terminateEditing))
|
chatManager.$currentSession
|
||||||
view.addGestureRecognizer(terminateEditGesture)
|
.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) {
|
||||||
@@ -90,4 +152,66 @@ class MainViewController: UIViewController {
|
|||||||
@objc func terminateEditing() {
|
@objc func terminateEditing() {
|
||||||
view.endEditing(true)
|
view.endEditing(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Chat Methods
|
||||||
|
|
||||||
|
private func updateMessages(for sessionId: String?) {
|
||||||
|
guard let sessionId else {
|
||||||
|
messages = []
|
||||||
|
updateEmptyState()
|
||||||
|
tableView.reloadData()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = chatManager.messages[sessionId] ?? []
|
||||||
|
updateEmptyState()
|
||||||
|
tableView.reloadData()
|
||||||
|
|
||||||
|
if !messages.isEmpty {
|
||||||
|
let indexPath = IndexPath(row: messages.count - 1, section: 0)
|
||||||
|
tableView.scrollToRow(at: indexPath, at: .bottom, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateEmptyState() {
|
||||||
|
emptyStateView.isHidden = !messages.isEmpty
|
||||||
|
tableView.isHidden = messages.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internal Methods for Preview/Testing
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
func setMessagesForPreview(_ previewMessages: [ChatMessage]) {
|
||||||
|
messages = previewMessages
|
||||||
|
updateEmptyState()
|
||||||
|
tableView.reloadData()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UITableViewDataSource
|
||||||
|
|
||||||
|
extension MainViewController: UITableViewDataSource {
|
||||||
|
func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
|
||||||
|
messages.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "ChatCell", for: indexPath) as! ChatCell
|
||||||
|
let message = messages[indexPath.row]
|
||||||
|
cell.configure(with: message)
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UITableViewDelegate
|
||||||
|
|
||||||
|
extension MainViewController: UITableViewDelegate {
|
||||||
|
func tableView(_: UITableView, heightForRowAt _: IndexPath) -> CGFloat {
|
||||||
|
UITableView.automaticDimension
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_: UITableView, estimatedHeightForRowAt _: IndexPath) -> CGFloat {
|
||||||
|
60
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
//
|
||||||
|
// ChatCell.swift
|
||||||
|
// Intelligents
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 6/26/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import Then
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class ChatCell: UITableViewCell {
|
||||||
|
// MARK: - UI Components
|
||||||
|
|
||||||
|
private lazy var avatarImageView = UIImageView().then {
|
||||||
|
$0.contentMode = .scaleAspectFit
|
||||||
|
$0.layer.cornerRadius = 16
|
||||||
|
$0.layer.cornerCurve = .continuous
|
||||||
|
$0.clipsToBounds = true
|
||||||
|
$0.backgroundColor = .systemGray5
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var messageContainerView = UIView().then {
|
||||||
|
$0.layer.cornerRadius = 12
|
||||||
|
$0.layer.cornerCurve = .continuous
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var messageLabel = UILabel().then {
|
||||||
|
$0.numberOfLines = 0
|
||||||
|
$0.font = .systemFont(ofSize: 16)
|
||||||
|
$0.textColor = .label
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var timestampLabel = UILabel().then {
|
||||||
|
$0.font = .systemFont(ofSize: 12)
|
||||||
|
$0.textColor = .systemGray
|
||||||
|
$0.textAlignment = .right
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var stackView = UIStackView().then {
|
||||||
|
$0.axis = .horizontal
|
||||||
|
$0.spacing = 12
|
||||||
|
$0.alignment = .top
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var messageStackView = UIStackView().then {
|
||||||
|
$0.axis = .vertical
|
||||||
|
$0.spacing = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
private var message: ChatMessage?
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder _: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Setup
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
backgroundColor = .clear
|
||||||
|
selectionStyle = .none
|
||||||
|
|
||||||
|
contentView.addSubview(stackView)
|
||||||
|
|
||||||
|
messageStackView.addArrangedSubview(messageContainerView)
|
||||||
|
messageStackView.addArrangedSubview(timestampLabel)
|
||||||
|
|
||||||
|
messageContainerView.addSubview(messageLabel)
|
||||||
|
|
||||||
|
stackView.addArrangedSubview(avatarImageView)
|
||||||
|
stackView.addArrangedSubview(messageStackView)
|
||||||
|
|
||||||
|
stackView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
avatarImageView.snp.makeConstraints { make in
|
||||||
|
make.size.equalTo(32)
|
||||||
|
}
|
||||||
|
|
||||||
|
messageLabel.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
messageStackView.snp.makeConstraints { make in
|
||||||
|
make.width.lessThanOrEqualTo(250)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Configuration
|
||||||
|
|
||||||
|
func configure(with message: ChatMessage) {
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
messageLabel.text = message.content
|
||||||
|
|
||||||
|
if let createdDate = message.createdDate {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .none
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
timestampLabel.text = formatter.string(from: createdDate)
|
||||||
|
} else {
|
||||||
|
timestampLabel.text = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
switch message.role {
|
||||||
|
case .user:
|
||||||
|
configureUserMessage()
|
||||||
|
case .assistant:
|
||||||
|
configureAssistantMessage()
|
||||||
|
case .system:
|
||||||
|
configureSystemMessage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureUserMessage() {
|
||||||
|
// User message - align to right
|
||||||
|
stackView.semanticContentAttribute = .forceRightToLeft
|
||||||
|
messageContainerView.backgroundColor = .systemBlue
|
||||||
|
messageLabel.textColor = .white
|
||||||
|
avatarImageView.image = UIImage(systemName: "person.circle.fill")
|
||||||
|
avatarImageView.tintColor = .systemBlue
|
||||||
|
timestampLabel.textAlignment = .left
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureAssistantMessage() {
|
||||||
|
// Assistant message - align to left
|
||||||
|
stackView.semanticContentAttribute = .forceLeftToRight
|
||||||
|
messageContainerView.backgroundColor = .systemGray6
|
||||||
|
messageLabel.textColor = .label
|
||||||
|
avatarImageView.image = UIImage(systemName: "brain.head.profile")
|
||||||
|
avatarImageView.tintColor = .systemPurple
|
||||||
|
timestampLabel.textAlignment = .right
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureSystemMessage() {
|
||||||
|
// System message - center aligned
|
||||||
|
stackView.semanticContentAttribute = .forceLeftToRight
|
||||||
|
messageContainerView.backgroundColor = .systemYellow.withAlphaComponent(0.3)
|
||||||
|
messageLabel.textColor = .label
|
||||||
|
avatarImageView.image = UIImage(systemName: "gear")
|
||||||
|
avatarImageView.tintColor = .systemOrange
|
||||||
|
timestampLabel.textAlignment = .center
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
//
|
||||||
|
// AttachmentCellViewModel.swift
|
||||||
|
// Intelligents
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 6/26/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct AttachmentCellViewModel: ChatCellViewModel {
|
||||||
|
var cellType: CellType = .attachment
|
||||||
|
var id: String
|
||||||
|
var attachments: [AttachmentViewModel]
|
||||||
|
var parentMessageId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AttachmentViewModel: Codable, Identifiable, Equatable, Hashable {
|
||||||
|
var id: String
|
||||||
|
var url: String
|
||||||
|
var mimeType: String?
|
||||||
|
var fileName: String?
|
||||||
|
var size: Int64?
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
//
|
||||||
|
// CellType.swift
|
||||||
|
// Intelligents
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 6/26/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum CellType: String, Codable, CaseIterable {
|
||||||
|
case userMessage
|
||||||
|
case assistantMessage
|
||||||
|
case systemMessage
|
||||||
|
case attachment
|
||||||
|
case contextReference
|
||||||
|
case workflowStatus
|
||||||
|
case transcription
|
||||||
|
case loading
|
||||||
|
case error
|
||||||
|
}
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
//
|
||||||
|
// ChatCellViewModels.swift
|
||||||
|
// Intelligents
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 6/26/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol ChatCellViewModel: Codable, Identifiable, Equatable, Hashable {
|
||||||
|
var cellType: CellType { get }
|
||||||
|
var id: String { get }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserMessageCellViewModel: ChatCellViewModel {
|
||||||
|
var cellType: CellType = .userMessage
|
||||||
|
var id: String
|
||||||
|
var content: String
|
||||||
|
var attachments: [AttachmentViewModel]
|
||||||
|
var timestamp: Date?
|
||||||
|
var isRetrying: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AssistantMessageCellViewModel: ChatCellViewModel {
|
||||||
|
var cellType: CellType = .assistantMessage
|
||||||
|
var id: String
|
||||||
|
var content: String
|
||||||
|
var attachments: [AttachmentViewModel]
|
||||||
|
var timestamp: Date?
|
||||||
|
var isStreaming: Bool
|
||||||
|
var model: String?
|
||||||
|
var tokens: Int?
|
||||||
|
var canRetry: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SystemMessageCellViewModel: ChatCellViewModel {
|
||||||
|
var cellType: CellType = .systemMessage
|
||||||
|
var id: String
|
||||||
|
var content: String
|
||||||
|
var timestamp: Date?
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
//
|
||||||
|
// ContextReferenceCellViewModel.swift
|
||||||
|
// Intelligents
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 6/26/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ContextReferenceCellViewModel: ChatCellViewModel {
|
||||||
|
var cellType: CellType = .contextReference
|
||||||
|
var id: String
|
||||||
|
var references: [ChatManager.ContextReference]
|
||||||
|
var parentMessageId: String
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
//
|
||||||
|
// ErrorCellViewModel.swift
|
||||||
|
// Intelligents
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 6/26/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ErrorCellViewModel: ChatCellViewModel {
|
||||||
|
var cellType: CellType = .error
|
||||||
|
var id: String
|
||||||
|
var errorMessage: String
|
||||||
|
var canRetry: Bool
|
||||||
|
var retryAction: String?
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
//
|
||||||
|
// LoadingCellViewModel.swift
|
||||||
|
// Intelligents
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 6/26/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct LoadingCellViewModel: ChatCellViewModel {
|
||||||
|
var cellType: CellType = .loading
|
||||||
|
var id: String
|
||||||
|
var message: String?
|
||||||
|
var progress: Double?
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
//
|
||||||
|
// WorkflowStatusCellViewModel.swift
|
||||||
|
// Intelligents
|
||||||
|
//
|
||||||
|
// Created by 秋星桥 on 6/26/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct WorkflowStatusCellViewModel: ChatCellViewModel {
|
||||||
|
var cellType: CellType = .workflowStatus
|
||||||
|
var id: String
|
||||||
|
var workflow: ChatManager.WorkflowEventData
|
||||||
|
var parentMessageId: String
|
||||||
|
}
|
||||||
+6
@@ -66,6 +66,12 @@ public class InputBoxViewModel: ObservableObject {
|
|||||||
.assign(to: \.canSend, on: self)
|
.assign(to: \.canSend, on: self)
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func clearAllAttachments() {
|
||||||
|
imageAttachments.removeAll()
|
||||||
|
fileAttachments.removeAll()
|
||||||
|
documentAttachments.removeAll()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Text Management
|
// MARK: - Text Management
|
||||||
|
|||||||
Reference in New Issue
Block a user