import Foundation import UniformTypeIdentifiers import UIKit enum ChatAttachmentError: LocalizedError { case unsupportedType(String) case imageTooLarge(String) case textTooLarge(String) case unreadableFile(String) case unsupportedImageFormat(String) case tooManyAttachments(Int) var errorDescription: String? { switch self { case let .unsupportedType(filename): return "Unsupported file type for '\(filename)'. Use PNG/JPEG images or text-based files." case let .imageTooLarge(filename): return "Image '\(filename)' exceeds the 6 MB upload limit." case let .textTooLarge(filename): return "Text file '\(filename)' exceeds the 8 MB upload limit." case let .unreadableFile(filename): return "Could not read '\(filename)'." case let .unsupportedImageFormat(filename): return "Image '\(filename)' could not be converted to PNG or JPEG." case let .tooManyAttachments(limit): return "You can attach up to \(limit) files per message." } } } enum SybilChatAttachmentSupport { static let maxAttachmentsPerMessage = 8 static let maxImageBytes = 6 * 1024 * 1024 static let maxTextBytes = 8 * 1024 * 1024 static let maxTextCharacters = 200_000 private static let supportedTextExtensions: Set = [ "txt", "md", "markdown", "csv", "tsv", "json", "jsonl", "xml", "yaml", "yml", "html", "htm", "css", "js", "jsx", "ts", "tsx", "py", "rb", "java", "c", "cc", "cpp", "h", "hpp", "go", "rs", "sh", "sql", "log", "toml", "ini", "cfg", "conf", "swift", "kt", "m", "mm" ] private static let supportedTextMimeTypes: Set = [ "application/json", "application/ld+json", "application/sql", "application/toml", "application/x-httpd-php", "application/x-javascript", "application/x-sh", "application/xml", "application/yaml", "application/x-yaml", "image/svg+xml" ] static func attachmentSummary(_ attachments: [ChatAttachment]) -> String { guard !attachments.isEmpty else { return "" } let names = attachments.map(\.filename).joined(separator: ", ") return attachments.count == 1 ? names : "Attached: \(names)" } static func metadataValue(for attachments: [ChatAttachment]) -> JSONValue? { guard !attachments.isEmpty else { return nil } return .object([ "attachments": .array(attachments.map(\.jsonValue)) ]) } static func buildAttachments(from urls: [URL]) throws -> [ChatAttachment] { try urls.map { try buildAttachment(fromFileURL: $0) } } static func buildImageAttachment(image: UIImage, filename: String = "pasted-image.jpg") throws -> ChatAttachment { if let pngData = image.pngData(), pngData.count <= maxImageBytes { return try buildImageAttachment(data: pngData, filename: filename, contentType: .png) } guard let jpegData = image.jpegData(compressionQuality: 0.92) else { throw ChatAttachmentError.unsupportedImageFormat(filename) } return try buildImageAttachment(data: jpegData, filename: filename, contentType: .jpeg) } static func buildTextAttachment(text: String, filename: String = "pasted-text.txt", mimeType: String = "text/plain") throws -> ChatAttachment { let data = Data(text.utf8) return try buildTextAttachment(data: data, filename: filename, mimeType: mimeType) } @MainActor static func buildAttachments(from itemProviders: [NSItemProvider]) async throws -> [ChatAttachment] { var attachments: [ChatAttachment] = [] for provider in itemProviders { if let fileURL = try await loadFileURL(from: provider) { attachments.append(try buildAttachment(fromFileURL: fileURL)) continue } if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) { if let attachment = try await loadImageAttachment(from: provider) { attachments.append(attachment) } } } return attachments } static func previewText(for attachment: ChatAttachment) -> String { let normalized = (attachment.text ?? "") .replacingOccurrences(of: "\r", with: "") .trimmingCharacters(in: .whitespacesAndNewlines) if normalized.isEmpty { return "(empty file)" } if normalized.count <= 280 { return normalized } let endIndex = normalized.index(normalized.startIndex, offsetBy: 280) return normalized[.. UIImage? { guard attachment.kind == .image, let dataURL = attachment.dataUrl, let data = decodeDataURL(dataURL) else { return nil } return UIImage(data: data) } private static func buildAttachment(fromFileURL url: URL) throws -> ChatAttachment { let accessed = url.startAccessingSecurityScopedResource() defer { if accessed { url.stopAccessingSecurityScopedResource() } } let filename = url.lastPathComponent.isEmpty ? "attachment" : url.lastPathComponent let resourceValues = try? url.resourceValues(forKeys: [.contentTypeKey]) let contentType = resourceValues?.contentType ?? UTType(filenameExtension: url.pathExtension) let data: Data do { data = try Data(contentsOf: url) } catch { throw ChatAttachmentError.unreadableFile(filename) } if contentType?.conforms(to: .image) == true { return try buildImageAttachment(data: data, filename: filename, contentType: contentType) } if isTextLike(contentType: contentType, mimeType: contentType?.preferredMIMEType, filename: filename) { return try buildTextAttachment(data: data, filename: filename, mimeType: contentType?.preferredMIMEType ?? "text/plain") } throw ChatAttachmentError.unsupportedType(filename) } static func buildImageAttachment(data: Data, filename: String, contentType: UTType?) throws -> ChatAttachment { var mimeType = contentType?.preferredMIMEType var payload = data if mimeType != "image/png" && mimeType != "image/jpeg" { guard let image = UIImage(data: data) else { throw ChatAttachmentError.unsupportedImageFormat(filename) } if let pngData = image.pngData(), pngData.count <= maxImageBytes { payload = pngData mimeType = "image/png" } else if let jpegData = image.jpegData(compressionQuality: 0.92) { payload = jpegData mimeType = "image/jpeg" } else { throw ChatAttachmentError.unsupportedImageFormat(filename) } } if payload.count > maxImageBytes { throw ChatAttachmentError.imageTooLarge(filename) } let normalizedMimeType = (mimeType == "image/png") ? "image/png" : "image/jpeg" let dataUrl = "data:\(normalizedMimeType);base64,\(payload.base64EncodedString())" return .image( filename: filename, mimeType: normalizedMimeType, sizeBytes: payload.count, dataUrl: dataUrl ) } private static func buildTextAttachment(data: Data, filename: String, mimeType: String) throws -> ChatAttachment { if data.count > maxTextBytes { throw ChatAttachmentError.textTooLarge(filename) } let normalized = String(decoding: data, as: UTF8.self) .replacingOccurrences(of: "\r\n", with: "\n") .replacingOccurrences(of: "\u{0000}", with: "") let truncated = normalized.count > maxTextCharacters let trimmedText: String if truncated { let endIndex = normalized.index(normalized.startIndex, offsetBy: maxTextCharacters) trimmedText = String(normalized[.. Bool { if let contentType { if contentType.conforms(to: .text) || contentType.conforms(to: .plainText) || contentType.conforms(to: .sourceCode) { return true } if contentType.conforms(to: .json) || contentType.conforms(to: .xml) { return true } } if let mimeType { if mimeType.hasPrefix("text/") { return true } if supportedTextMimeTypes.contains(mimeType.lowercased()) { return true } } let ext = URL(fileURLWithPath: filename).pathExtension.lowercased() return supportedTextExtensions.contains(ext) } private static func decodeDataURL(_ value: String) -> Data? { guard let separator = value.firstIndex(of: ",") else { return nil } let encoded = value[value.index(after: separator)...] return Data(base64Encoded: String(encoded)) } @MainActor private static func loadFileURL(from provider: NSItemProvider) async throws -> URL? { guard provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) else { return nil } return try await withCheckedThrowingContinuation { continuation in provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, error in if let error { continuation.resume(throwing: error) return } if let url = item as? URL { continuation.resume(returning: url) return } if let data = item as? Data, let url = URL(dataRepresentation: data, relativeTo: nil) { continuation.resume(returning: url) return } if let string = item as? String, let url = URL(string: string) { continuation.resume(returning: url) return } continuation.resume(returning: nil) } } } @MainActor private static func loadImageAttachment(from provider: NSItemProvider) async throws -> ChatAttachment? { let preferredImageType: UTType = if provider.hasItemConformingToTypeIdentifier(UTType.png.identifier) { .png } else if provider.hasItemConformingToTypeIdentifier(UTType.jpeg.identifier) { .jpeg } else { .image } if let data = try await loadDataRepresentation(from: provider, type: preferredImageType) { let filenameExtension = preferredImageType.preferredFilenameExtension ?? "jpg" let filename = "pasted-image.\(filenameExtension)" return try buildImageAttachment(data: data, filename: filename, contentType: preferredImageType) } if let image = try await loadUIImage(from: provider), let jpegData = image.jpegData(compressionQuality: 0.92) { return try buildImageAttachment(data: jpegData, filename: "pasted-image.jpg", contentType: .jpeg) } return nil } @MainActor private static func loadDataRepresentation(from provider: NSItemProvider, type: UTType) async throws -> Data? { guard provider.hasItemConformingToTypeIdentifier(type.identifier) else { return nil } return try await withCheckedThrowingContinuation { continuation in provider.loadDataRepresentation(forTypeIdentifier: type.identifier) { data, error in if let error { continuation.resume(throwing: error) return } continuation.resume(returning: data) } } } @MainActor private static func loadUIImage(from provider: NSItemProvider) async throws -> UIImage? { guard provider.canLoadObject(ofClass: UIImage.self) else { return nil } return try await withCheckedThrowingContinuation { continuation in provider.loadObject(ofClass: UIImage.self) { object, error in if let error { continuation.resume(throwing: error) return } continuation.resume(returning: object as? UIImage) } } } }