355 lines
13 KiB
Swift
355 lines
13 KiB
Swift
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<String> = [
|
|
"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<String> = [
|
|
"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[..<endIndex].trimmingCharacters(in: .whitespacesAndNewlines) + "..."
|
|
}
|
|
|
|
static func image(for attachment: ChatAttachment) -> 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[..<endIndex])
|
|
} else {
|
|
trimmedText = normalized
|
|
}
|
|
|
|
return .text(
|
|
filename: filename,
|
|
mimeType: mimeType,
|
|
sizeBytes: data.count,
|
|
text: trimmedText,
|
|
truncated: truncated
|
|
)
|
|
}
|
|
|
|
private static func isTextLike(contentType: UTType?, mimeType: String?, filename: String) -> 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)
|
|
}
|
|
}
|
|
}
|
|
}
|