chore: update graphql support for fetching initial user status (#12905)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced a new context management system for intelligent features,
enabling the collection and preparation of metadata from both web view
and GraphQL sources.
- Added a service for managing GraphQL API interactions, including user,
workspace, subscription, and quota queries.
- Enabled searching documents within a workspace using a new GraphQL
query and input structure.

- **Enhancements**
- Expanded chat session and chat history search capabilities with
additional filter and pagination options.

- **Refactor**
- Replaced the previous context management class with a more
comprehensive and modular implementation.
- Improved handling of cookies for network requests to ensure session
continuity.

- **Style**
- Minor code style and formatting improvements for clarity and
consistency.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Lakr
2025-06-24 05:03:35 +00:00
committed by GitHub
parent 63de20c3d5
commit 10139205b4
17 changed files with 585 additions and 97 deletions
@@ -0,0 +1,73 @@
//
// IntelligentContext+GraphQL.swift
// Intelligents
//
// Created by on 6/23/25.
//
import AffineGraphQL
import Apollo
import ApolloAPI
import UIKit
extension IntelligentContext {
func prepareMetadataFromGraphQlClient(completion: @escaping ([QLMetadataKey: Any]) -> Void) {
var newMetadata: [QLMetadataKey: Any] = [:]
let dispatchGroup = DispatchGroup()
let service = QLService.shared
dispatchGroup.enter()
service.fetchCurrentUser { user in
if let user {
newMetadata[.userIdentifierKey] = user.id
newMetadata[.userNameKey] = user.name
newMetadata[.userEmailKey] = user.email
if let avatarUrl = user.avatarUrl {
newMetadata[.userAvatarKey] = avatarUrl
}
}
dispatchGroup.leave()
}
dispatchGroup.enter()
service.fetchUserSettings { settings in
if let settings {
newMetadata[.userSettingsKey] = settings
}
dispatchGroup.leave()
}
dispatchGroup.enter()
service.fetchWorkspaces { workspaces in
newMetadata[.workspacesCountKey] = workspaces.count
newMetadata[.workspacesKey] = workspaces.map { workspace in
[
"id": workspace.id,
"team": workspace.team,
]
}
dispatchGroup.leave()
}
dispatchGroup.enter()
service.fetchSubscription { subscription in
if let subscription {
newMetadata[.subscriptionStatusKey] = subscription.status
newMetadata[.subscriptionPlanKey] = subscription.plan
}
dispatchGroup.leave()
}
dispatchGroup.enter()
service.fetchQuota { quota in
if let quota {
newMetadata[.storageQuotaKey] = quota.storageQuota
}
dispatchGroup.leave()
}
dispatchGroup.notify(queue: .main) {
completion(newMetadata)
}
}
}
@@ -0,0 +1,33 @@
//
// IntelligentContext+WebView.swift
// Intelligents
//
// Created by on 6/23/25.
//
import UIKit
import WebKit
extension IntelligentContext {
func prepareMetadataFrom(webView: WKWebView, completion: @escaping ([WebViewMetadataKey: Any]) -> Void) {
var newMetadata: [WebViewMetadataKey: Any] = [:]
let dispatchGroup = DispatchGroup()
let keysAndScripts: [(WebViewMetadataKey, BridgedWindowScript)] = [
(.currentDocId, .getCurrentDocId),
(.currentWorkspaceId, .getCurrentWorkspaceId),
(.currentServerBaseUrl, .getCurrentServerBaseUrl),
(.currentI18nLocale, .getCurrentI18nLocale),
]
for (key, script) in keysAndScripts {
DispatchQueue.main.async {
webView.evaluateScript(script) { value in
newMetadata[key] = value // if unable to fetch, clear it
dispatchGroup.leave()
}
}
dispatchGroup.enter()
}
dispatchGroup.wait()
completion(newMetadata)
}
}
@@ -0,0 +1,146 @@
//
// IntelligentContext.swift
// Intelligents
//
// Created by on 6/17/25.
//
import Combine
import Foundation
import WebKit
public class IntelligentContext {
public static let shared = IntelligentContext()
public var webView: WKWebView!
public private(set) var qlMetadata: [QLMetadataKey: Any] = [:]
public enum QLMetadataKey: String, CaseIterable {
case accountIdentifier
case userIdentifierKey
case userNameKey
case userEmailKey
case userAvatarKey
case userSettingsKey
case workspacesCountKey
case workspacesKey
case subscriptionStatusKey
case subscriptionPlanKey
case storageQuotaKey
case storageUsedKey
}
var isAccountValid: Bool {
true
}
public private(set) var webViewMetadata: [WebViewMetadataKey: Any] = [:]
public enum WebViewMetadataKey: String, CaseIterable {
case currentDocId
case currentWorkspaceId
case currentServerBaseUrl
case currentI18nLocale
}
public lazy var temporaryDirectory: URL = {
let tempDir = FileManager.default.temporaryDirectory
return tempDir.appendingPathComponent("IntelligentContext")
}()
private init() {}
public func preparePresent(_ completion: @escaping (Result<Void, Error>) -> Void) {
DispatchQueue.global(qos: .userInitiated).async { [self] in
prepareTemporaryDirectory()
let webViewGroup = DispatchGroup()
var webViewMetadataResult: [WebViewMetadataKey: Any] = [:]
webViewGroup.enter()
prepareMetadataFrom(webView: webView) { metadata in
webViewMetadataResult = metadata
webViewGroup.leave()
}
webViewGroup.wait()
webViewMetadata = webViewMetadataResult
if let baseUrlString = webViewMetadataResult[.currentServerBaseUrl] as? String,
let url = URL(string: baseUrlString)
{
QLService.shared.setEndpoint(base: url)
}
let gqlGroup = DispatchGroup()
var gqlMetadataResult: [QLMetadataKey: Any] = [:]
gqlGroup.enter()
prepareMetadataFromGraphQlClient { metadata in
gqlMetadataResult = metadata
gqlGroup.leave()
}
gqlGroup.wait()
qlMetadata = gqlMetadataResult
dumpMetadataContents()
DispatchQueue.main.async {
completion(.success(()))
}
}
}
func dumpMetadataContents() {
print("\n========== IntelligentContext Metadata ==========")
print("-- QL Metadata --")
for key in QLMetadataKey.allCases {
let value = qlMetadata[key] ?? "<nil>"
print("\(key.rawValue): \(value)")
}
print("\n-- WebView Metadata --")
for key in WebViewMetadataKey.allCases {
let value = webViewMetadata[key] ?? "<nil>"
print("\(key.rawValue): \(value)")
}
print("===============================================\n")
}
func prepareTemporaryDirectory() {
if FileManager.default.fileExists(atPath: temporaryDirectory.path) {
try? FileManager.default.removeItem(at: temporaryDirectory)
}
try? FileManager.default.createDirectory(
at: temporaryDirectory,
withIntermediateDirectories: true
)
}
}
/*
dumpMetadataContents sample:
========== IntelligentContext Metadata ==========
-- QL Metadata --
accountIdentifier: <nil>
userIdentifierKey: 82a5a6f0-xxxx-xxxx-xxxx-0de4be320696
userNameKey: Dev User
userEmailKey: xxx@xxxxx.xxx
userAvatarKey: https://avatar.affineassets.com/82a5a6f0-xxxx-xxxx-xxxx-0de4be320696-avatar-1733191099480
userSettingsKey: {
"__typename" = UserSettingsType;
receiveInvitationEmail = 1;
receiveMentionEmail = 1;
}
workspacesCountKey: 8
workspacesKey: [["id": "a0d781bf-xxxx-xxxx-xxxx-19394bad7f24", "team": false], ["id": "b00d1110-xxxx-xxxx-xxxx-4bc39685af7c", "team": false], ["id": "5559196a-xxxx-xxxx-xxxx-fc9ee6e2dbf9", "team": false], ["team": true, "id": "0f58ea6f-xxxx-xxxx-xxxx-30c4b01a346a"], ["id": "c4e72530-xxxx-xxxx-xxxx-888a166c8155", "team": true], ["id": "c924e653-xxxx-xxxx-xxxx-ed4be3a7d7c8", "team": false], ["id": "ac772e5a-xxxx-xxxx-xxxx-4e2049259408", "team": true], ["id": "4dc9c0ca-xxxx-xxxx-xxxx-7b84184f7e1d", "team": true]]
subscriptionStatusKey: case(AffineGraphQL.SubscriptionStatus.active)
subscriptionPlanKey: case(AffineGraphQL.SubscriptionPlan.pro)
storageQuotaKey: 10737418240
storageUsedKey: <nil>
-- WebView Metadata --
currentDocId: <null>
currentWorkspaceId: <null>
currentServerBaseUrl: https://affine.fail
currentI18nLocale: en
===============================================
*/
@@ -0,0 +1,21 @@
//
// QLService+URLSessionCookieClient.swift
// Intelligents
//
// Created by on 6/23/25.
//
import Apollo
import Foundation
extension QLService {
final class URLSessionCookieClient: URLSessionClient {
public init() {
super.init()
session.configuration.httpCookieStorage = .init()
HTTPCookieStorage.shared.cookies?.forEach { cookie in
self.session.configuration.httpCookieStorage?.setCookie(cookie)
}
}
}
}
@@ -0,0 +1,92 @@
import AffineGraphQL
import Apollo
import Foundation
public final class QLService {
public static let shared = QLService()
private var endpointURL: URL
public private(set) var client: ApolloClient
private init() {
let store = ApolloStore()
endpointURL = URL(string: "https://app.affine.pro/graphql")!
let urlSessionClient = URLSessionCookieClient()
let networkTransport = RequestChainNetworkTransport(
interceptorProvider: DefaultInterceptorProvider(client: urlSessionClient, store: store),
endpointURL: endpointURL
)
client = ApolloClient(networkTransport: networkTransport, store: store)
}
public func setEndpoint(base: URL) {
var url: URL = base
if url.lastPathComponent != "graphql" {
url = url.appendingPathComponent("graphql")
}
print("[*] setting endpoint for qlservice: \(url.absoluteString)")
let store = ApolloStore()
endpointURL = url
let urlSessionClient = URLSessionCookieClient()
let networkTransport = RequestChainNetworkTransport(
interceptorProvider: DefaultInterceptorProvider(client: urlSessionClient, store: store),
endpointURL: url
)
client = ApolloClient(networkTransport: networkTransport, store: store)
}
public func fetchCurrentUser(completion: @escaping (GetCurrentUserQuery.Data.CurrentUser?) -> Void) {
client.fetch(query: GetCurrentUserQuery()) { result in
switch result {
case let .success(graphQLResult):
completion(graphQLResult.data?.currentUser)
case .failure:
completion(nil)
}
}
}
public func fetchUserSettings(completion: @escaping (GetUserSettingsQuery.Data.CurrentUser.Settings?) -> Void) {
client.fetch(query: GetUserSettingsQuery()) { result in
switch result {
case let .success(graphQLResult):
completion(graphQLResult.data?.currentUser?.settings)
case .failure:
completion(nil)
}
}
}
public func fetchWorkspaces(completion: @escaping ([GetWorkspacesQuery.Data.Workspace]) -> Void) {
client.fetch(query: GetWorkspacesQuery()) { result in
switch result {
case let .success(graphQLResult):
completion(graphQLResult.data?.workspaces ?? [])
case .failure:
completion([])
}
}
}
public func fetchSubscription(completion: @escaping (SubscriptionQuery.Data.CurrentUser.Subscription?) -> Void) {
client.fetch(query: SubscriptionQuery()) { result in
switch result {
case let .success(graphQLResult):
completion(graphQLResult.data?.currentUser?.subscriptions.first)
case .failure:
completion(nil)
}
}
}
public func fetchQuota(completion: @escaping (QuotaQuery.Data.CurrentUser.Quota?) -> Void) {
client.fetch(query: QuotaQuery()) { result in
switch result {
case let .success(graphQLResult):
completion(graphQLResult.data?.currentUser?.quota)
case .failure:
completion(nil)
}
}
}
}
@@ -6,15 +6,3 @@ import Apollo
import Foundation
public enum Intelligents {}
private extension Intelligents {
private final class URLSessionCookieClient: URLSessionClient {
init() {
super.init()
session.configuration.httpCookieStorage = .init()
HTTPCookieStorage.shared.cookies?.forEach { cookie in
self.session.configuration.httpCookieStorage?.setCookie(cookie)
}
}
}
}
@@ -13,8 +13,8 @@ protocol InputBoxFunctionBarDelegate: AnyObject {
func functionBarDidTapSend(_ functionBar: InputBoxFunctionBar)
}
private let unselectedColor: UIColor = UIColor.affineIconPrimary
private let selectedColor: UIColor = UIColor.affineIconActivated
private let unselectedColor: UIColor = .affineIconPrimary
private let selectedColor: UIColor = .affineIconActivated
class InputBoxFunctionBar: UIView {
weak var delegate: InputBoxFunctionBarDelegate?
@@ -62,7 +62,7 @@ class InputBoxImageBar: UIScrollView {
//
let idsToAdd = newIds.subtracting(currentIds)
var initialXOffset = attachmentViewModels.reduce(0) { $0 + $1.imageCell.frame.width + cellSpacing }
for attachment in imageAttachments {
for attachment in imageAttachments {
if idsToAdd.contains(attachment.id),
let data = attachment.data,
let image = UIImage(data: data)
@@ -1,5 +1,5 @@
//
// ApplicationBridgedWindowScript.swift
// BridgedWindowScript.swift
// App
//
// Created by on 2025/1/8.
@@ -22,14 +22,14 @@ enum BridgedWindowScript: 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: BridgedWindowScript, callback: @escaping (Any?) -> ()) {
func evaluateScript(_ script: BridgedWindowScript, 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 {
}
}
}
@@ -1,72 +0,0 @@
//
// IntelligentContext.swift
// Intelligents
//
// Created by on 6/17/25.
//
import Combine
import Foundation
import WebKit
public class IntelligentContext {
// shared across the app, we expect our app to have a single context and webview
public static let shared = IntelligentContext()
public var webView: WKWebView!
public private(set) var metadata: [MetadataKey: Any] = [:]
public enum MetadataKey: String {
case currentDocId
case currentWorkspaceId
case currentServerBaseUrl
case currentI18nLocale
}
public lazy var temporaryDirectory: URL = {
let tempDir = FileManager.default.temporaryDirectory
return tempDir.appendingPathComponent("IntelligentContext")
}()
private init() {}
public func preparePresent(_ completion: @escaping () -> Void) {
DispatchQueue.global(qos: .userInitiated).async { [self] in
prepareTemporaryDirectory()
let group = DispatchGroup()
var newMetadata: [MetadataKey: Any] = [:]
let keysAndScripts: [(MetadataKey, BridgedWindowScript)] = [
(.currentDocId, .getCurrentDocId),
(.currentWorkspaceId, .getCurrentWorkspaceId),
(.currentServerBaseUrl, .getCurrentServerBaseUrl),
(.currentI18nLocale, .getCurrentI18nLocale)
]
for (key, script) in keysAndScripts {
DispatchQueue.main.async {
self.webView.evaluateScript(script) { value in
newMetadata[key] = value // if unable to fetch, clear it
group.leave()
}
}
group.enter()
}
self.metadata = newMetadata
group.wait()
print("IntelligentContext metadata prepared: \(self.metadata)")
DispatchQueue.main.async {
completion()
}
}
}
func prepareTemporaryDirectory() {
if FileManager.default.fileExists(atPath: temporaryDirectory.path) {
try? FileManager.default.removeItem(at: temporaryDirectory)
}
try? FileManager.default.createDirectory(
at: temporaryDirectory,
withIntermediateDirectories: true
)
}
}