feat(mobile): adapt new endpoint (#14778)

#### PR Dependency Tree


* **PR #14778** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
DarkSky
2026-04-04 20:39:42 +08:00
committed by GitHub
parent 4be0cba9b5
commit 5a6c65085a
247 changed files with 4199 additions and 702 deletions

View File

@@ -16,7 +16,7 @@ let package = Package(
dependencies: [
.package(path: "../AffineGraphQL"),
.package(path: "../AffineResources"),
.package(url: "https://github.com/apollographql/apollo-ios.git", from: "1.23.0"),
.package(url: "https://github.com/apollographql/apollo-ios.git", from: "1.25.4"),
.package(url: "https://github.com/apple/swift-collections.git", from: "1.4.1"),
.package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.7.1"),
.package(url: "https://github.com/SwifterSwift/SwifterSwift.git", from: "6.2.0"),

View File

@@ -6,8 +6,6 @@
//
import AffineGraphQL
import Apollo
import ApolloAPI
import EventSource
import Foundation
import MarkdownParser
@@ -170,56 +168,44 @@ private extension ChatManager {
"files": [String](), // attachment in context, keep nil for now
"searchMode": editorData.isSearchEnabled ? "MUST" : "AUTO",
]
let attachmentCount = [
editorData.fileAttachments.count,
editorData.imageAttachments.count,
].reduce(0, +)
let attachmentFieldName = attachmentCount > 1 && attachmentCount != 0 ? "options.blobs" : "options.blob"
let uploadableAttachments: [GraphQLFile] = [
editorData.fileAttachments.map { file -> GraphQLFile in
let uploadableAttachments: [CopilotAttachmentUpload] = [
editorData.fileAttachments.map { file -> CopilotAttachmentUpload in
.init(
fieldName: attachmentFieldName,
originalName: file.name,
mimeType: mimeType(text: file.name),
data: file.data ?? .init()
)
},
editorData.imageAttachments.map { image -> GraphQLFile in
editorData.imageAttachments.map { image -> CopilotAttachmentUpload in
.init(
fieldName: attachmentFieldName,
originalName: "image.jpg",
mimeType: mimeType(pathExtension: "jpg"),
data: image.imageData
)
},
].flatMap(\.self)
assert(uploadableAttachments.allSatisfy { !($0.data?.isEmpty ?? true) })
let input = CreateChatMessageInput(
attachments: [],
blob: attachmentCount == 1 ? "" : .none,
blobs: attachmentCount > 1 && attachmentCount != 0 ? .some([]) : .none,
content: .some(contextSnippet.isEmpty ? editorData.text : "\(contextSnippet)\n\(editorData.text)"),
params: .some(messageParameters),
sessionId: sessionId
)
let mutation = CreateCopilotMessageMutation(options: input)
QLService.shared.client.upload(operation: mutation, files: uploadableAttachments) { result in
print("[*] createCopilotMessage result: \(result)")
DispatchQueue.main.async {
switch result {
case let .success(graphQLResult):
guard let messageIdentifier = graphQLResult.data?.createCopilotMessage else {
self.report(sessionId, ChatError.invalidResponse)
self.delete(sessionId: sessionId, vmId: viewModelId)
return
}
assert(uploadableAttachments.allSatisfy { !$0.data.isEmpty })
let messageContent = contextSnippet.isEmpty ? editorData.text : "\(contextSnippet)\n\(editorData.text)"
Task {
do {
let messageIdentifier = try await QLService.shared.createCopilotMessage(
workspaceId: IntelligentContext.shared.currentWorkspaceId ?? "",
sessionId: sessionId,
content: messageContent,
params: messageParameters,
attachments: uploadableAttachments
)
DispatchQueue.main.async {
self.startStreamingResponse(
sessionId: sessionId,
messageId: messageIdentifier,
applyingTo: viewModelId
)
case let .failure(error):
}
} catch {
DispatchQueue.main.async {
self.report(sessionId, error)
self.delete(sessionId: sessionId, vmId: viewModelId)
}
}
}

View File

@@ -0,0 +1,310 @@
import AffineGraphQL
import CryptoKit
import Foundation
struct CopilotAttachmentUpload {
let data: Data
let mimeType: String
let originalName: String
}
extension QLService {
func createCopilotMessage(
workspaceId: String,
sessionId: String,
content: String,
params: AffineGraphQL.JSON,
attachments: [CopilotAttachmentUpload]
) async throws -> String {
if attachments.isEmpty {
return try await createCopilotMessage(
input: CreateChatMessageInput(
attachments: .none,
blob: .none,
blobs: .none,
content: .some(content),
params: .some(params),
sessionId: sessionId
)
)
}
let attachmentURLs = try await uploadCopilotAttachments(
workspaceId: workspaceId,
attachments: attachments
)
return try await createCopilotMessage(
input: CreateChatMessageInput(
attachments: .some(attachmentURLs),
blob: .none,
blobs: .none,
content: .some(content),
params: .some(params),
sessionId: sessionId
)
)
}
private func createCopilotMessage(
input: CreateChatMessageInput
) async throws -> String {
let mutation = CreateCopilotMessageMutation(options: input)
let data = try await perform(mutation: mutation)
return data.createCopilotMessage
}
private func uploadCopilotAttachments(
workspaceId: String,
attachments: [CopilotAttachmentUpload]
) async throws -> [String] {
var uploadedURLs = [String]()
uploadedURLs.reserveCapacity(attachments.count)
for attachment in attachments {
let uploadedURL = try await uploadCopilotAttachment(
workspaceId: workspaceId,
attachment: attachment
)
uploadedURLs.append(uploadedURL)
}
return uploadedURLs
}
private func uploadCopilotAttachment(
workspaceId: String,
attachment: CopilotAttachmentUpload
) async throws -> String {
let blobKey = blobKey(for: attachment)
let mutation = CreateBlobUploadMutation(
workspaceId: workspaceId,
key: blobKey,
size: attachment.data.count,
mime: attachment.mimeType
)
let uploadData = try await perform(mutation: mutation)
let upload = uploadData.createBlobUpload
if upload.alreadyUploaded == true {
return try await resolveSignedBlobURL(workspaceId: workspaceId, blobKey: blobKey)
}
switch upload.method {
case .case(.graphql):
throw NSError(
domain: "QLService",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Direct upload is not available"]
)
case .case(.presigned):
guard let uploadURL = upload.uploadUrl else {
throw NSError(
domain: "QLService",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Missing presigned upload URL"]
)
}
try await put(
urlString: uploadURL,
headers: stringHeaders(from: upload.headers),
data: attachment.data,
contentType: attachment.mimeType
)
_ = try await perform(mutation: CompleteBlobUploadMutation(
workspaceId: workspaceId,
key: blobKey,
uploadId: .none,
parts: .none
))
case .case(.multipart):
guard let uploadId = upload.uploadId, let partSize = upload.partSize else {
throw NSError(
domain: "QLService",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Missing multipart upload metadata"]
)
}
let parts = try await uploadMultipartAttachment(
workspaceId: workspaceId,
blobKey: blobKey,
uploadId: uploadId,
partSize: partSize,
data: attachment.data,
uploadedParts: upload.uploadedParts ?? []
)
_ = try await perform(mutation: CompleteBlobUploadMutation(
workspaceId: workspaceId,
key: blobKey,
uploadId: .some(uploadId),
parts: .some(parts)
))
case .unknown(_):
throw NSError(
domain: "QLService",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Unknown blob upload method"]
)
}
return try await resolveSignedBlobURL(workspaceId: workspaceId, blobKey: blobKey)
}
private func uploadMultipartAttachment(
workspaceId: String,
blobKey: String,
uploadId: String,
partSize: Int,
data: Data,
uploadedParts: [CreateBlobUploadMutation.Data.CreateBlobUpload.UploadedPart]
) async throws -> [BlobUploadPartInput] {
var partsMap = Dictionary(uniqueKeysWithValues: uploadedParts.map {
($0.partNumber, $0.etag)
})
let totalParts = Int(ceil(Double(data.count) / Double(partSize)))
for partNumber in 1...totalParts {
if partsMap[partNumber] != nil {
continue
}
let start = (partNumber - 1) * partSize
let end = min(start + partSize, data.count)
let chunk = data.subdata(in: start..<end)
let part = try await fetch(query: GetBlobUploadPartUrlQuery(
workspaceId: workspaceId,
key: blobKey,
uploadId: uploadId,
partNumber: partNumber
)).workspace.blobUploadPartUrl
let response = try await put(
urlString: part.uploadUrl,
headers: stringHeaders(from: part.headers),
data: chunk,
contentType: nil
)
guard let etag = response.value(forHTTPHeaderField: "ETag") else {
throw NSError(
domain: "QLService",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Multipart upload response missing ETag"]
)
}
partsMap[partNumber] = etag
}
return partsMap
.sorted { $0.key < $1.key }
.map { BlobUploadPartInput(etag: $0.value, partNumber: $0.key) }
}
private func resolveSignedBlobURL(
workspaceId: String,
blobKey: String
) async throws -> String {
var components = URLComponents(
url: serverBaseURL
.appendingPathComponent("api")
.appendingPathComponent("workspaces")
.appendingPathComponent(workspaceId)
.appendingPathComponent("blobs")
.appendingPathComponent(blobKey),
resolvingAgainstBaseURL: false
)
components?.queryItems = [
URLQueryItem(name: "redirect", value: "manual"),
]
guard let url = components?.url else {
throw NSError(
domain: "QLService",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Invalid blob redirect URL"]
)
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
let (data, response) = try await sendAuthenticatedRequest(request)
guard (200..<300).contains(response.statusCode) else {
throw NSError(
domain: "QLService",
code: response.statusCode,
userInfo: [NSLocalizedDescriptionKey: "Blob redirect request failed"]
)
}
guard
let payload = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let signedURL = payload["url"] as? String
else {
throw NSError(
domain: "QLService",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Missing signed blob URL"]
)
}
return signedURL
}
@discardableResult
private func put(
urlString: String,
headers: [String: String]?,
data: Data,
contentType: String?
) async throws -> HTTPURLResponse {
guard let url = URL(string: urlString, relativeTo: serverBaseURL) else {
throw NSError(
domain: "QLService",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Invalid upload URL"]
)
}
var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.httpBody = data
request.setValue(String(data.count), forHTTPHeaderField: "Content-Length")
if let contentType {
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
}
headers?.forEach { key, value in
request.setValue(value, forHTTPHeaderField: key)
}
let (_, response) = try await sendAuthenticatedRequest(request)
guard (200..<300).contains(response.statusCode) else {
throw NSError(
domain: "QLService",
code: response.statusCode,
userInfo: [NSLocalizedDescriptionKey: "Upload request failed"]
)
}
return response
}
private func stringHeaders(
from headers: AffineGraphQL.JSONObject?
) -> [String: String]? {
guard let object = headers?.object else {
return nil
}
var result = [String: String]()
for (key, value) in object {
if let value = value as? String {
result[key] = value
}
}
return result.isEmpty ? nil : result
}
private func blobKey(for attachment: CopilotAttachmentUpload) -> String {
let digest = SHA256.hash(data: attachment.data)
let encoded = Data(digest).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
let sanitizedName = attachment.originalName
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: " ", with: "_")
return "\(encoded)-\(sanitizedName)"
}
}

View File

@@ -5,12 +5,13 @@ import Foundation
public final class QLService {
public static let shared = QLService()
private var endpointURL: URL
private var urlSessionClient: URLSessionCookieClient
public var client: ApolloClient
private init() {
let store = ApolloStore()
endpointURL = URL(string: "https://app.affine.pro/graphql")!
let urlSessionClient = URLSessionCookieClient()
urlSessionClient = URLSessionCookieClient()
let networkTransport = RequestChainNetworkTransport(
interceptorProvider: DefaultInterceptorProvider(client: urlSessionClient, store: store),
endpointURL: endpointURL
@@ -27,7 +28,7 @@ public final class QLService {
let store = ApolloStore()
endpointURL = url
let urlSessionClient = URLSessionCookieClient()
urlSessionClient = URLSessionCookieClient()
let networkTransport = RequestChainNetworkTransport(
interceptorProvider: DefaultInterceptorProvider(client: urlSessionClient, store: store),
endpointURL: url
@@ -35,6 +36,67 @@ public final class QLService {
client = ApolloClient(networkTransport: networkTransport, store: store)
}
var serverBaseURL: URL {
if endpointURL.lastPathComponent == "graphql" {
return endpointURL.deletingLastPathComponent()
}
return endpointURL
}
func sendAuthenticatedRequest(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
try await withCheckedThrowingContinuation { continuation in
urlSessionClient.sendRequest(request) { result in
continuation.resume(with: result)
}
}
}
func perform<Mutation: GraphQLMutation>(mutation: Mutation) async throws -> Mutation.Data {
try await withCheckedThrowingContinuation { continuation in
client.perform(mutation: mutation) { result in
switch result {
case let .success(graphQLResult):
if let data = graphQLResult.data {
continuation.resume(returning: data)
} else if let error = graphQLResult.errors?.first {
continuation.resume(throwing: error)
} else {
continuation.resume(throwing: NSError(
domain: "QLService",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Missing GraphQL data"]
))
}
case let .failure(error):
continuation.resume(throwing: error)
}
}
}
}
func fetch<Query: GraphQLQuery>(query: Query) async throws -> Query.Data {
try await withCheckedThrowingContinuation { continuation in
client.fetch(query: query) { result in
switch result {
case let .success(graphQLResult):
if let data = graphQLResult.data {
continuation.resume(returning: data)
} else if let error = graphQLResult.errors?.first {
continuation.resume(throwing: error)
} else {
continuation.resume(throwing: NSError(
domain: "QLService",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Missing GraphQL data"]
))
}
case let .failure(error):
continuation.resume(throwing: error)
}
}
}
}
public func fetchCurrentUser(completion: @escaping (GetCurrentUserQuery.Data.CurrentUser?) -> Void) {
client.fetch(query: GetCurrentUserQuery()) { result in
switch result {