diff --git a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj index 2bba41391f..55f436619a 100644 --- a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj +++ b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 77; + objectVersion = 56; objects = { /* Begin PBXBuildFile section */ @@ -92,6 +92,8 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ C45499AB2D140B5000E21978 /* NBStore */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = NBStore; sourceTree = ""; }; @@ -340,13 +342,9 @@ ); inputFileListPaths = ( ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AFFiNE/Pods-AFFiNE-frameworks.sh\"\n"; diff --git a/packages/frontend/apps/ios/App/App/AffineViewController+AIButton.swift b/packages/frontend/apps/ios/App/App/AffineViewController+AIButton.swift index f2fc6614eb..a5353ad768 100644 --- a/packages/frontend/apps/ios/App/App/AffineViewController+AIButton.swift +++ b/packages/frontend/apps/ios/App/App/AffineViewController+AIButton.swift @@ -12,14 +12,14 @@ extension AFFiNEViewController: IntelligentsButtonDelegate { func onIntelligentsButtonTapped(_ button: IntelligentsButton) { IntelligentContext.shared.webView = webView! button.beginProgress() - - IntelligentContext.shared.preparePresent() { result in + + IntelligentContext.shared.preparePresent { result in button.stopProgress() switch result { - case .success(let success): + case .success: let controller = IntelligentsController() self.present(controller, animated: true) - case .failure(let failure): + case let .failure(failure): let alert = UIAlertController( title: "Error", message: failure.localizedDescription, diff --git a/packages/frontend/apps/ios/App/App/AffineViewController.swift b/packages/frontend/apps/ios/App/App/AffineViewController.swift index 22227845ab..5ac07b77b1 100644 --- a/packages/frontend/apps/ios/App/App/AffineViewController.swift +++ b/packages/frontend/apps/ios/App/App/AffineViewController.swift @@ -13,15 +13,15 @@ class AFFiNEViewController: CAPBridgeViewController { intelligentsButton.delegate = self dismissIntelligentsButton() } - + override func webViewConfiguration(for instanceConfiguration: InstanceConfiguration) -> WKWebViewConfiguration { let configuration = super.webViewConfiguration(for: instanceConfiguration) return configuration } - + 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() { let plugins: [CAPPlugin] = [ @@ -43,6 +43,3 @@ class AFFiNEViewController: CAPBridgeViewController { } } } - - - diff --git a/packages/frontend/apps/ios/App/App/AppConfigManager.swift b/packages/frontend/apps/ios/App/App/AppConfigManager.swift index 0042a27511..9118bda4dd 100644 --- a/packages/frontend/apps/ios/App/App/AppConfigManager.swift +++ b/packages/frontend/apps/ios/App/App/AppConfigManager.swift @@ -4,9 +4,9 @@ final class AppConfigManager { struct AppConfig: Decodable { let affineVersion: String } - - static var affineVersion: String? = nil - + + static var affineVersion: String? + static func getAffineVersion() -> String { if affineVersion == nil { let file = Bundle(for: AppConfigManager.self).url(forResource: "capacitor.config", withExtension: "json")! @@ -14,7 +14,7 @@ final class AppConfigManager { let config = try! JSONDecoder().decode(AppConfig.self, from: data) affineVersion = config.affineVersion } - + return affineVersion! } } diff --git a/packages/frontend/apps/ios/App/App/ApplicationBridgedWindowScript.swift b/packages/frontend/apps/ios/App/App/ApplicationBridgedWindowScript.swift index aa2c080e6d..4f16daa364 100644 --- a/packages/frontend/apps/ios/App/App/ApplicationBridgedWindowScript.swift +++ b/packages/frontend/apps/ios/App/App/ApplicationBridgedWindowScript.swift @@ -22,14 +22,14 @@ enum ApplicationBridgedWindowScript: String { var requiresAsyncContext: Bool { switch self { - case .getCurrentDocContentInMarkdown, .createNewDocByMarkdownInCurrentWorkspace: return true - default: return false + case .getCurrentDocContentInMarkdown, .createNewDocByMarkdownInCurrentWorkspace: true + default: false } } } extension WKWebView { - func evaluateScript(_ script: ApplicationBridgedWindowScript, callback: @escaping (Any?) -> ()) { + func evaluateScript(_ script: ApplicationBridgedWindowScript, callback: @escaping (Any?) -> Void) { if script.requiresAsyncContext { callAsyncJavaScript( script.rawValue, @@ -38,7 +38,7 @@ extension WKWebView { in: .page ) { result in switch result { - case .success(let input): + case let .success(input): callback(input) case .failure: callback(nil) @@ -49,5 +49,3 @@ extension WKWebView { } } } - - diff --git a/packages/frontend/apps/ios/App/App/JSValueContainerExt.swift b/packages/frontend/apps/ios/App/App/JSValueContainerExt.swift index 94827635b5..d93b8015dc 100644 --- a/packages/frontend/apps/ios/App/App/JSValueContainerExt.swift +++ b/packages/frontend/apps/ios/App/App/JSValueContainerExt.swift @@ -10,44 +10,44 @@ enum RequestParamError: Error { case request(key: String) } -extension JSValueContainer { - public func getStringEnsure(_ key: String) throws -> String { - guard let str = self.getString(key) else { +public extension JSValueContainer { + func getStringEnsure(_ key: String) throws -> String { + guard let str = getString(key) else { throw RequestParamError.request(key: key) } return str } - - public func getIntEnsure(_ key: String) throws -> Int { - guard let int = self.getInt(key) else { + + func getIntEnsure(_ key: String) throws -> Int { + guard let int = getInt(key) else { throw RequestParamError.request(key: key) } return int } - - public func getDoubleEnsure(_ key: String) throws -> Double { - guard let doub = self.getDouble(key) else { + + func getDoubleEnsure(_ key: String) throws -> Double { + guard let doub = getDouble(key) else { throw RequestParamError.request(key: key) } return doub } - - public func getBoolEnsure(_ key: String) throws -> Bool { - guard let bool = self.getBool(key) else { + + func getBoolEnsure(_ key: String) throws -> Bool { + guard let bool = getBool(key) else { throw RequestParamError.request(key: key) } return bool } - - public func getArrayEnsure(_ key: String) throws -> JSArray { - guard let arr = self.getArray(key) else { + + func getArrayEnsure(_ key: String) throws -> JSArray { + guard let arr = getArray(key) else { throw RequestParamError.request(key: key) } return arr } - - public func getArrayEnsure(_ key: String, _ ofType: T.Type) throws -> [T] { - guard let arr = self.getArray(key, ofType) else { + + func getArrayEnsure(_ key: String, _ ofType: T.Type) throws -> [T] { + guard let arr = getArray(key, ofType) else { throw RequestParamError.request(key: key) } return arr diff --git a/packages/frontend/apps/ios/App/App/Mutex.swift b/packages/frontend/apps/ios/App/App/Mutex.swift index f2e4a4c5b8..4058153c98 100644 --- a/packages/frontend/apps/ios/App/App/Mutex.swift +++ b/packages/frontend/apps/ios/App/App/Mutex.swift @@ -8,15 +8,15 @@ import Foundation final class Mutex: @unchecked Sendable { - private let lock = NSLock.init() + private let lock = NSLock() private var wrapped: Wrapped - + init(_ wrapped: Wrapped) { self.wrapped = wrapped } - + func withLock(_ body: @Sendable (inout Wrapped) throws -> R) rethrows -> R { - self.lock.lock() + lock.lock() defer { self.lock.unlock() } return try body(&wrapped) } diff --git a/packages/frontend/apps/ios/App/App/Plugins/Auth/AuthPlugin.swift b/packages/frontend/apps/ios/App/App/Plugins/Auth/AuthPlugin.swift index 8f48412499..b7d63b7b98 100644 --- a/packages/frontend/apps/ios/App/App/Plugins/Auth/AuthPlugin.swift +++ b/packages/frontend/apps/ios/App/App/Plugins/Auth/AuthPlugin.swift @@ -10,7 +10,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin { CAPPluginMethod(name: "signInPassword", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "signOut", returnType: CAPPluginReturnPromise), ] - + @objc public func signInMagicLink(_ call: CAPPluginCall) { Task { do { @@ -18,7 +18,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin { let email = try call.getStringEnsure("email") let token = try call.getStringEnsure("token") let clientNonce = call.getString("clientNonce") - + let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/auth/magic-link", headers: [:], body: ["email": email, "token": token, "client_nonce": clientNonce]) if response.statusCode >= 400 { @@ -28,19 +28,19 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin { call.reject("Failed to sign in") } } - + guard let token = try self.tokenFromCookie(endpoint) else { call.reject("token not found") return } - + call.resolve(["token": token]) } catch { call.reject("Failed to sign in, \(error)", nil, error) } } } - + @objc public func signInOauth(_ call: CAPPluginCall) { Task { do { @@ -48,9 +48,9 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin { let code = try call.getStringEnsure("code") let state = try call.getStringEnsure("state") let clientNonce = call.getString("clientNonce") - + let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/oauth/callback", headers: [:], body: ["code": code, "state": state, "client_nonce": clientNonce]) - + if response.statusCode >= 400 { if let textBody = String(data: data, encoding: .utf8) { call.reject(textBody) @@ -58,19 +58,19 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin { call.reject("Failed to sign in") } } - + guard let token = try self.tokenFromCookie(endpoint) else { call.reject("token not found") return } - + call.resolve(["token": token]) } catch { call.reject("Failed to sign in, \(error)", nil, error) } } } - + @objc public func signInPassword(_ call: CAPPluginCall) { Task { do { @@ -79,12 +79,12 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin { let password = try call.getStringEnsure("password") let verifyToken = call.getString("verifyToken") let challenge = call.getString("challenge") - + let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/auth/sign-in", headers: [ "x-captcha-token": verifyToken, - "x-captcha-challenge": challenge + "x-captcha-challenge": challenge, ], body: ["email": email, "password": password]) - + if response.statusCode >= 400 { if let textBody = String(data: data, encoding: .utf8) { call.reject(textBody) @@ -92,24 +92,24 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin { call.reject("Failed to sign in") } } - + guard let token = try self.tokenFromCookie(endpoint) else { call.reject("token not found") return } - + call.resolve(["token": token]) } catch { call.reject("Failed to sign in, \(error)", nil, error) } } } - + @objc public func signOut(_ call: CAPPluginCall) { Task { do { let endpoint = try call.getStringEnsure("endpoint") - + let (data, response) = try await self.fetch(endpoint, method: "GET", action: "/api/auth/sign-out", headers: [:], body: nil) if response.statusCode >= 400 { @@ -119,20 +119,19 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin { call.reject("Failed to sign in") } } - - + call.resolve(["ok": true]) } catch { call.reject("Failed to sign in, \(error)", nil, error) } } } - + private func tokenFromCookie(_ endpoint: String) throws -> String? { guard let endpointUrl = URL(string: endpoint) else { throw AuthError.invalidEndpoint } - + if let cookie = HTTPCookieStorage.shared.cookies(for: endpointUrl)?.first(where: { $0.name == "affine_session" }) { @@ -141,14 +140,14 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin { return nil } } - - private func fetch(_ endpoint: String, method: String, action: String, headers: Dictionary, 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 { throw AuthError.invalidEndpoint } - - var request = URLRequest(url: targetUrl); - request.httpMethod = method; + + var request = URLRequest(url: targetUrl) + request.httpMethod = method request.httpShouldHandleCookies = true for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) @@ -159,8 +158,8 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin { } request.setValue(AppConfigManager.getAffineVersion(), forHTTPHeaderField: "x-affine-version") 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 { throw AuthError.internalError } diff --git a/packages/frontend/apps/ios/App/App/Plugins/Cookie/IntelligentsPlugin.swift b/packages/frontend/apps/ios/App/App/Plugins/Cookie/IntelligentsPlugin.swift index cff51cf720..552a985422 100644 --- a/packages/frontend/apps/ios/App/App/Plugins/Cookie/IntelligentsPlugin.swift +++ b/packages/frontend/apps/ios/App/App/Plugins/Cookie/IntelligentsPlugin.swift @@ -1,8 +1,8 @@ import Capacitor import Foundation -//@objc(IntelligentsPlugin) -//public class IntelligentsPlugin: CAPPlugin, CAPBridgedPlugin { +// @objc(IntelligentsPlugin) +// public class IntelligentsPlugin: CAPPlugin, CAPBridgedPlugin { // public let identifier = "IntelligentsPlugin" // public let jsName = "Intelligents" // public let pluginMethods: [CAPPluginMethod] = [ @@ -33,4 +33,4 @@ import Foundation // call.resolve() // } // } -//} +// } diff --git a/packages/frontend/apps/ios/App/App/Plugins/NBStore/NBStorePlugin.swift b/packages/frontend/apps/ios/App/App/Plugins/NBStore/NBStorePlugin.swift index a5bfcfcf31..96a81ca51f 100644 --- a/packages/frontend/apps/ios/App/App/Plugins/NBStore/NBStorePlugin.swift +++ b/packages/frontend/apps/ios/App/App/Plugins/NBStore/NBStorePlugin.swift @@ -4,7 +4,7 @@ import Foundation @objc(NbStorePlugin) public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { private let docStoragePool: DocStoragePool = newDocStoragePool() - + public let identifier = "NbStorePlugin" public let jsName = "NbStoreDocStorage" public let pluginMethods: [CAPPluginMethod] = [ @@ -37,7 +37,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { CAPPluginMethod(name: "getBlobUploadedAt", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "setBlobUploadedAt", returnType: CAPPluginReturnPromise), ] - + @objc func connect(_ call: CAPPluginCall) { Task { do { @@ -52,10 +52,10 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { let peerDir = documentDir.appending(path: "workspaces") .appending(path: spaceType) .appending(path: - peer - .replacing(#/[\/!@#$%^&*()+~`"':;,?<>|]/#, with: "_") - .replacing(/_+/, with: "_") - .replacing(/_+$/, with: "")) + peer + .replacing(#/[\/!@#$%^&*()+~`"':;,?<>|]/#, with: "_") + .replacing(/_+/, with: "_") + .replacing(/_+$/, with: "")) try FileManager.default.createDirectory(atPath: peerDir.path(), withIntermediateDirectories: true) let db = peerDir.appending(path: spaceId + ".db") try await docStoragePool.connect(universalId: id, path: db.path()) @@ -65,7 +65,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { } } } - + @objc func disconnect(_ call: CAPPluginCall) { Task { do { @@ -77,7 +77,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { } } } - + @objc func setSpaceId(_ call: CAPPluginCall) { Task { do { @@ -90,7 +90,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { } } } - + @objc func pushUpdate(_ call: CAPPluginCall) { Task { do { @@ -104,13 +104,13 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { } } } - + @objc func getDocSnapshot(_ call: CAPPluginCall) { Task { do { let id = try call.getStringEnsure("id") let docId = try call.getStringEnsure("docId") - + if let record = try await docStoragePool.getDocSnapshot(universalId: id, docId: docId) { call.resolve([ "docId": record.docId, @@ -125,7 +125,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { } } } - + @objc func setDocSnapshot(_ call: CAPPluginCall) { Task { do { @@ -143,7 +143,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { } } } - + @objc func getDocUpdates(_ call: CAPPluginCall) { Task { do { @@ -161,14 +161,14 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { } } } - + @objc func markUpdatesMerged(_ call: CAPPluginCall) { Task { do { let id = try call.getStringEnsure("id") let docId = try call.getStringEnsure("docId") let times = try call.getArrayEnsure("timestamps", Int64.self) - + let count = try await docStoragePool.markUpdatesMerged(universalId: id, docId: docId, updates: times) call.resolve(["count": count]) } catch { @@ -176,13 +176,13 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { } } } - + @objc func deleteDoc(_ call: CAPPluginCall) { Task { do { let id = try call.getStringEnsure("id") let docId = try call.getStringEnsure("docId") - + try await docStoragePool.deleteDoc(universalId: id, docId: docId) call.resolve() } catch { @@ -190,13 +190,13 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { } } } - + @objc func getDocClocks(_ call: CAPPluginCall) { Task { do { let id = try call.getStringEnsure("id") let after = call.getInt("after") - + let docClocks = try await docStoragePool.getDocClocks( universalId: id, after: after != nil ? Int64(after!) : nil @@ -211,7 +211,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { } } } - + @objc func getDocClock(_ call: CAPPluginCall) { Task { do { @@ -230,7 +230,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { } } } - + @objc func getBlob(_ call: CAPPluginCall) { Task { do { @@ -242,7 +242,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { "data": blob.data, "mime": blob.mime, "size": blob.size, - "createdAt": blob.createdAt + "createdAt": blob.createdAt, ]) } else { call.resolve() @@ -252,7 +252,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { } } } - + @objc func setBlob(_ call: CAPPluginCall) { Task { do { @@ -267,7 +267,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { } } } - + @objc func deleteBlob(_ call: CAPPluginCall) { Task { do { @@ -281,7 +281,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { } } } - + @objc func releaseBlobs(_ call: CAPPluginCall) { Task { do { @@ -293,7 +293,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { } } } - + @objc func listBlobs(_ call: CAPPluginCall) { Task { do { @@ -311,13 +311,13 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { } } } - + @objc func getPeerRemoteClocks(_ call: CAPPluginCall) { Task { do { let id = try call.getStringEnsure("id") let peer = try call.getStringEnsure("peer") - + let clocks = try await docStoragePool.getPeerRemoteClocks(universalId: id, peer: peer) let mapped = clocks.map { [ "docId": $0.docId, @@ -329,14 +329,14 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { } } } - + @objc func getPeerRemoteClock(_ call: CAPPluginCall) { Task { do { let id = try call.getStringEnsure("id") let peer = try call.getStringEnsure("peer") let docId = try call.getStringEnsure("docId") - + if let clock = try await docStoragePool.getPeerRemoteClock(universalId: id, peer: peer, docId: docId) { call.resolve([ "docId": clock.docId, @@ -345,13 +345,13 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { } else { call.resolve() } - + } catch { call.reject("Failed to get peer remote clock, \(error)", nil, error) } } } - + @objc func setPeerRemoteClock(_ call: CAPPluginCall) { Task { do { @@ -371,13 +371,13 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { } } } - + @objc func getPeerPulledRemoteClocks(_ call: CAPPluginCall) { Task { do { let id = try call.getStringEnsure("id") let peer = try call.getStringEnsure("peer") - + let clocks = try await docStoragePool.getPeerPulledRemoteClocks(universalId: id, peer: peer) let mapped = clocks.map { [ "docId": $0.docId, @@ -389,14 +389,14 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { } } } - + @objc func getPeerPulledRemoteClock(_ call: CAPPluginCall) { Task { do { let id = try call.getStringEnsure("id") let peer = try call.getStringEnsure("peer") let docId = try call.getStringEnsure("docId") - + if let clock = try await docStoragePool.getPeerPulledRemoteClock(universalId: id, peer: peer, docId: docId) { call.resolve([ "docId": clock.docId, @@ -405,13 +405,13 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { } else { call.resolve() } - + } catch { call.reject("Failed to get peer pulled remote clock, \(error)", nil, error) } } } - + @objc func setPeerPulledRemoteClock(_ call: CAPPluginCall) { Task { do { @@ -419,7 +419,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { let peer = try call.getStringEnsure("peer") let docId = try call.getStringEnsure("docId") let timestamp = try call.getIntEnsure("timestamp") - + try await docStoragePool.setPeerPulledRemoteClock( universalId: id, peer: peer, @@ -432,7 +432,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { } } } - + @objc func getPeerPushedClock(_ call: CAPPluginCall) { Task { do { @@ -452,7 +452,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { } } } - + @objc func getPeerPushedClocks(_ call: CAPPluginCall) { Task { do { @@ -464,13 +464,13 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { "timestamp": $0.timestamp, ] } call.resolve(["clocks": mapped]) - + } catch { call.reject("Failed to get peer pushed clocks, \(error)", nil, error) } } } - + @objc func setPeerPushedClock(_ call: CAPPluginCall) { Task { do { @@ -478,7 +478,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { let peer = try call.getStringEnsure("peer") let docId = try call.getStringEnsure("docId") let timestamp = try call.getIntEnsure("timestamp") - + try await docStoragePool.setPeerPushedClock( universalId: id, peer: peer, @@ -491,29 +491,29 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { } } } - + @objc func getBlobUploadedAt(_ call: CAPPluginCall) { Task { do { let id = try call.getStringEnsure("id") let peer = try call.getStringEnsure("peer") let blobId = try call.getStringEnsure("blobId") - + let uploadedAt = try await docStoragePool.getBlobUploadedAt( universalId: id, peer: peer, blobId: blobId ) - + call.resolve([ - "uploadedAt": uploadedAt as Any + "uploadedAt": uploadedAt as Any, ]) } catch { call.reject("Failed to get blob uploaded, \(error)", nil, error) } } } - + @objc func setBlobUploadedAt(_ call: CAPPluginCall) { Task { do { @@ -521,7 +521,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin { let peer = try call.getStringEnsure("peer") let blobId = try call.getStringEnsure("blobId") let uploadedAt = call.getInt("uploadedAt") - + try await docStoragePool.setBlobUploadedAt( universalId: id, peer: peer, diff --git a/packages/frontend/apps/ios/App/App/SafeWKURLSchemeTask.swift b/packages/frontend/apps/ios/App/App/SafeWKURLSchemeTask.swift index e8fccce2fe..a25321d2a6 100644 --- a/packages/frontend/apps/ios/App/App/SafeWKURLSchemeTask.swift +++ b/packages/frontend/apps/ios/App/App/SafeWKURLSchemeTask.swift @@ -11,26 +11,24 @@ class SafeWKURLSchemeTask: WKURLSchemeTask, NSObject { var origin: any WKURLSchemeTask init(origin: any WKURLSchemeTask) { self.origin = origin - self.request = origin.request + request = origin.request } - + var request: URLRequest - - func didReceive(_ response: URLResponse) { + + func didReceive(_: URLResponse) { <#code#> } - - func didReceive(_ data: Data) { - self.origin.didReceive(<#T##response: URLResponse##URLResponse#>) + + func didReceive(_: Data) { + origin.didReceive(<#T##response: URLResponse##URLResponse#>) } - + func didFinish() { - self.origin.didFinish() + origin.didFinish() } - + func didFailWithError(_ error: any Error) { - self.origin.didFailWithError(error) + origin.didFailWithError(error) } - - } diff --git a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Schema/CustomScalars/JSONObject.swift b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Schema/CustomScalars/JSONObject.swift index bb6e37251e..b3daeb51b9 100644 --- a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Schema/CustomScalars/JSONObject.swift +++ b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Schema/CustomScalars/JSONObject.swift @@ -39,4 +39,3 @@ public enum CustomJSON: CustomScalarType, Hashable { hasher.combine(_jsonValue) } } - diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+ContextModels.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+ContextModels.swift new file mode 100644 index 0000000000..76add7d251 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+ContextModels.swift @@ -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 + } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+InputModels.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+InputModels.swift new file mode 100644 index 0000000000..64eb5fd8f2 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+InputModels.swift @@ -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 + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+WorkflowModels.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+WorkflowModels.swift new file mode 100644 index 0000000000..b1888759ae --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+WorkflowModels.swift @@ -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 + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager.swift new file mode 100644 index 0000000000..02c9f07dc0 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager.swift @@ -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() + 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) 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)" + } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatMessage.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatMessage.swift new file mode 100644 index 0000000000..45555aad0a --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatMessage.swift @@ -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 + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Model/BridgedWindowScript.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/BridgedWindowScript.swift similarity index 100% rename from packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Model/BridgedWindowScript.swift rename to packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/BridgedWindowScript.swift diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/DateTime.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/DateTime.swift index bbddd2e6b7..44af4291d9 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/DateTime.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/DateTime.swift @@ -11,7 +11,6 @@ import ApolloAPI import Foundation /// A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. -extension DateTime { extension DateTime { private static let formatter: DateFormatter = { let fmt = DateFormatter() @@ -20,8 +19,11 @@ extension DateTime { return fmt }() + init(date: Date) { + self.init(Self.formatter.string(from: date)) + } + var decoded: Date? { - return Self.formatter.date(from: self) + Self.formatter.date(from: self) } } -} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Model/UIColor+Affine.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/UIColor+Affine.swift similarity index 100% rename from packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Model/UIColor+Affine.swift rename to packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/UIColor+Affine.swift diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Model/UIImage+Affine.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/UIImage+Affine.swift similarity index 100% rename from packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Model/UIImage+Affine.swift rename to packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/UIImage+Affine.swift diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/QLService.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/QLService.swift index f080703d85..69a06c90a6 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/QLService.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/QLService.swift @@ -5,7 +5,7 @@ import Foundation public final class QLService { public static let shared = QLService() private var endpointURL: URL - public private(set) var client: ApolloClient + public var client: ApolloClient private init() { let store = ApolloStore() diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Input.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Input.swift index 7c9c89b3dc..260621dd39 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Input.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController+Input.swift @@ -75,7 +75,48 @@ extension MainViewController: InputBoxDelegate { } func inputBoxDidSend(_ inputBox: InputBox) { - print(#function, inputBox, inputBox.viewModel) + let inputData = inputBox.inputBoxData + + Task { @MainActor in + do { + let chatManager = ChatManager.shared + + if let currentSession = chatManager.currentSession { + try await chatManager.sendMessage( + content: inputData.text, + attachments: [], // TODO: Handle attachments + sessionId: currentSession.id + ) + } else { + guard let workspaceId = IntelligentContext.shared.webViewMetadata[.currentWorkspaceId] as? String, + !workspaceId.isEmpty + else { + showAlert(title: "Error", message: "No workspace available") + return + } + + let session = try await chatManager.createSession(workspaceId: workspaceId) + + try await chatManager.sendMessage( + content: inputData.text, + attachments: [], // TODO: Handle attachments + sessionId: session.id + ) + } + + inputBox.text = "" + inputBox.viewModel.clearAllAttachments() + + } catch { + showAlert(title: "Error", message: error.localizedDescription) + } + } + } + + private func showAlert(title: String, message: String) { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) } func inputBoxTextDidChange(_ text: String) { diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift index d3fc723020..e611c4c773 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/Controller/MainViewController/MainViewController.swift @@ -10,6 +10,27 @@ class MainViewController: UIViewController { $0.delegate = self } + lazy var tableView = UITableView().then { + $0.backgroundColor = .clear + $0.separatorStyle = .none + $0.delegate = self + $0.dataSource = self + $0.register(ChatCell.self, forCellReuseIdentifier: "ChatCell") + $0.keyboardDismissMode = .interactive + $0.contentInsetAdjustmentBehavior = .never + } + + lazy var emptyStateView = UIView().then { + $0.isHidden = true + } + + lazy var emptyStateLabel = UILabel().then { + $0.text = "Start a conversation..." + $0.font = .systemFont(ofSize: 18, weight: .medium) + $0.textColor = .systemGray + $0.textAlignment = .center + } + lazy var inputBox = InputBox().then { $0.delegate = self } @@ -28,8 +49,10 @@ class MainViewController: UIViewController { // MARK: - Properties + private var messages: [ChatMessage] = [] private var cancellables = Set() private let intelligentContext = IntelligentContext.shared + private let chatManager = ChatManager.shared var terminateEditGesture: UITapGestureRecognizer! // MARK: - Lifecycle @@ -38,21 +61,46 @@ class MainViewController: UIViewController { super.viewDidLoad() view.backgroundColor = .affineLayerBackgroundPrimary - let inputBox = InputBox().then { - $0.delegate = self - } - self.inputBox = inputBox + setupUI() + setupBindings() + view.isUserInteractionEnabled = true + terminateEditGesture = UITapGestureRecognizer(target: self, action: #selector(terminateEditing)) + view.addGestureRecognizer(terminateEditGesture) + } + + // MARK: - Setup + + private func setupUI() { view.addSubview(headerView) + view.addSubview(tableView) + view.addSubview(emptyStateView) view.addSubview(inputBox) view.addSubview(documentPickerHideDetector) view.addSubview(documentPickerView) + emptyStateView.addSubview(emptyStateLabel) + headerView.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide) make.leading.trailing.equalToSuperview() } + tableView.snp.makeConstraints { make in + make.top.equalTo(headerView.snp.bottom) + make.leading.trailing.equalToSuperview() + make.bottom.equalTo(inputBox.snp.top) + } + + emptyStateView.snp.makeConstraints { make in + make.center.equalTo(tableView) + make.width.lessThanOrEqualTo(tableView).inset(32) + } + + emptyStateLabel.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + inputBox.snp.makeConstraints { make in make.leading.trailing.equalToSuperview() make.bottom.equalTo(view.keyboardLayoutGuide.snp.top) @@ -67,10 +115,24 @@ class MainViewController: UIViewController { make.leading.trailing.equalToSuperview() make.height.equalTo(500) } + } - view.isUserInteractionEnabled = true - terminateEditGesture = UITapGestureRecognizer(target: self, action: #selector(terminateEditing)) - view.addGestureRecognizer(terminateEditGesture) + private func setupBindings() { + chatManager.$currentSession + .receive(on: DispatchQueue.main) + .sink { [weak self] session in + self?.updateMessages(for: session?.id) + } + .store(in: &cancellables) + + chatManager.$messages + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + if let sessionId = self?.chatManager.currentSession?.id { + self?.updateMessages(for: sessionId) + } + } + .store(in: &cancellables) } override func viewWillAppear(_ animated: Bool) { @@ -90,4 +152,66 @@ class MainViewController: UIViewController { @objc func terminateEditing() { view.endEditing(true) } + + // MARK: - Chat Methods + + private func updateMessages(for sessionId: String?) { + guard let sessionId else { + messages = [] + updateEmptyState() + tableView.reloadData() + return + } + + messages = chatManager.messages[sessionId] ?? [] + updateEmptyState() + tableView.reloadData() + + if !messages.isEmpty { + let indexPath = IndexPath(row: messages.count - 1, section: 0) + tableView.scrollToRow(at: indexPath, at: .bottom, animated: true) + } + } + + private func updateEmptyState() { + emptyStateView.isHidden = !messages.isEmpty + tableView.isHidden = messages.isEmpty + } + + // MARK: - Internal Methods for Preview/Testing + + #if DEBUG + func setMessagesForPreview(_ previewMessages: [ChatMessage]) { + messages = previewMessages + updateEmptyState() + tableView.reloadData() + } + #endif +} + +// MARK: - UITableViewDataSource + +extension MainViewController: UITableViewDataSource { + func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { + messages.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "ChatCell", for: indexPath) as! ChatCell + let message = messages[indexPath.row] + cell.configure(with: message) + return cell + } +} + +// MARK: - UITableViewDelegate + +extension MainViewController: UITableViewDelegate { + func tableView(_: UITableView, heightForRowAt _: IndexPath) -> CGFloat { + UITableView.automaticDimension + } + + func tableView(_: UITableView, estimatedHeightForRowAt _: IndexPath) -> CGFloat { + 60 + } } diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ChatCell.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ChatCell.swift new file mode 100644 index 0000000000..b56b880996 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ChatCell.swift @@ -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 + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/AttachmentCellViewModel.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/AttachmentCellViewModel.swift new file mode 100644 index 0000000000..fe12fe4a65 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/AttachmentCellViewModel.swift @@ -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? +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/CellType.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/CellType.swift new file mode 100644 index 0000000000..c5ee996ca3 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/CellType.swift @@ -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 +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ChatCellViewModels.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ChatCellViewModels.swift new file mode 100644 index 0000000000..57380b9d2f --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ChatCellViewModels.swift @@ -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? +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ContextReferenceCellViewModel.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ContextReferenceCellViewModel.swift new file mode 100644 index 0000000000..487c79d4c8 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ContextReferenceCellViewModel.swift @@ -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 +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ErrorCellViewModel.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ErrorCellViewModel.swift new file mode 100644 index 0000000000..7b8d8d1baf --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/ErrorCellViewModel.swift @@ -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? +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/LoadingCellViewModel.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/LoadingCellViewModel.swift new file mode 100644 index 0000000000..d9fd376f75 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/LoadingCellViewModel.swift @@ -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? +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/WorkflowStatusCellViewModel.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/WorkflowStatusCellViewModel.swift new file mode 100644 index 0000000000..a282931d70 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/ChatCell/ViewModel/WorkflowStatusCellViewModel.swift @@ -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 +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/ViewModel/InputBoxViewModel.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/ViewModel/InputBoxViewModel.swift index fccd1c418b..83e6c364d8 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/ViewModel/InputBoxViewModel.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Interface/View/InputBox/ViewModel/InputBoxViewModel.swift @@ -66,6 +66,12 @@ public class InputBoxViewModel: ObservableObject { .assign(to: \.canSend, on: self) .store(in: &cancellables) } + + public func clearAllAttachments() { + imageAttachments.removeAll() + fileAttachments.removeAll() + documentAttachments.removeAll() + } } // MARK: - Text Management