ios: adds file uploading
This commit is contained in:
33
ios/Packages/Sybil/Package.resolved
Normal file
33
ios/Packages/Sybil/Package.resolved
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"originHash" : "a6321e2b291c1094ca66f749c90095f05aac7f8c6b4a6e54e0e77a1bb0e1a79f",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "networkimage",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/NetworkImage",
|
||||
"state" : {
|
||||
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
|
||||
"version" : "6.0.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-cmark",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swiftlang/swift-cmark",
|
||||
"state" : {
|
||||
"revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
|
||||
"version" : "0.7.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-markdown-ui",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/swift-markdown-ui.git",
|
||||
"state" : {
|
||||
"revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
|
||||
"version" : "2.4.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
222
ios/Packages/Sybil/Sources/Sybil/SybilAttachmentViews.swift
Normal file
222
ios/Packages/Sybil/Sources/Sybil/SybilAttachmentViews.swift
Normal file
@@ -0,0 +1,222 @@
|
||||
import SwiftUI
|
||||
|
||||
enum SybilAttachmentTone {
|
||||
case composer
|
||||
case user
|
||||
case assistant
|
||||
}
|
||||
|
||||
struct SybilAttachmentListView: View {
|
||||
var attachments: [ChatAttachment]
|
||||
var tone: SybilAttachmentTone
|
||||
var onRemove: ((String) -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(attachments) { attachment in
|
||||
Group {
|
||||
if attachment.kind == .image {
|
||||
imageCard(attachment)
|
||||
} else {
|
||||
textCard(attachment)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func imageCard(_ attachment: ChatAttachment) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if let image = SybilChatAttachmentSupport.image(for: attachment) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 180)
|
||||
.clipped()
|
||||
} else {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(Color.black.opacity(0.18))
|
||||
Image(systemName: "photo")
|
||||
.font(.system(size: 22, weight: .medium))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.frame(height: 140)
|
||||
}
|
||||
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "photo")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(titleColor.opacity(0.92))
|
||||
.frame(width: 26, height: 26)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color.white.opacity(0.06))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.white.opacity(0.08), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(attachment.filename)
|
||||
.font(.sybil(.footnote, weight: .medium))
|
||||
.foregroundStyle(titleColor)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(attachment.mimeType)
|
||||
.font(.sybil(.caption2))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if let onRemove {
|
||||
removeButton(for: attachment.id, onRemove: onRemove)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
.background(cardBackground)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(cardBorder, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func textCard(_ attachment: ChatAttachment) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "doc.text")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(titleColor.opacity(0.92))
|
||||
.frame(width: 26, height: 26)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color.white.opacity(0.06))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.white.opacity(0.08), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(attachment.filename)
|
||||
.font(.sybil(.footnote, weight: .medium))
|
||||
.foregroundStyle(titleColor)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(
|
||||
attachment.truncated == true
|
||||
? "\(attachment.mimeType) • truncated"
|
||||
: attachment.mimeType
|
||||
)
|
||||
.font(.sybil(.caption2))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if let onRemove {
|
||||
removeButton(for: attachment.id, onRemove: onRemove)
|
||||
}
|
||||
}
|
||||
|
||||
Text(SybilChatAttachmentSupport.previewText(for: attachment))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(bodyColor)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color.black.opacity(0.16))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(Color.white.opacity(0.05), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
.padding(12)
|
||||
.background(cardBackground)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(cardBorder, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private func removeButton(for attachmentID: String, onRemove: @escaping (String) -> Void) -> some View {
|
||||
Button {
|
||||
onRemove(attachmentID)
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.frame(width: 24, height: 24)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(Color.white.opacity(0.06))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Remove attachment")
|
||||
}
|
||||
|
||||
private var cardBackground: some ShapeStyle {
|
||||
switch tone {
|
||||
case .composer:
|
||||
return AnyShapeStyle(
|
||||
LinearGradient(
|
||||
colors: [SybilTheme.surface.opacity(0.86), SybilTheme.surfaceStrong.opacity(0.78)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
case .user:
|
||||
return AnyShapeStyle(Color.black.opacity(0.14))
|
||||
case .assistant:
|
||||
return AnyShapeStyle(
|
||||
LinearGradient(
|
||||
colors: [SybilTheme.surface.opacity(0.58), SybilTheme.surfaceStrong.opacity(0.42)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var cardBorder: Color {
|
||||
switch tone {
|
||||
case .composer:
|
||||
return SybilTheme.border.opacity(0.82)
|
||||
case .user:
|
||||
return Color.white.opacity(0.12)
|
||||
case .assistant:
|
||||
return SybilTheme.border.opacity(0.58)
|
||||
}
|
||||
}
|
||||
|
||||
private var titleColor: Color {
|
||||
switch tone {
|
||||
case .composer, .assistant:
|
||||
return SybilTheme.text
|
||||
case .user:
|
||||
return SybilTheme.text
|
||||
}
|
||||
}
|
||||
|
||||
private var bodyColor: Color {
|
||||
switch tone {
|
||||
case .composer, .assistant:
|
||||
return SybilTheme.text.opacity(0.94)
|
||||
case .user:
|
||||
return SybilTheme.text.opacity(0.96)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,13 @@ private struct MessageBubble: View {
|
||||
)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if !message.attachments.isEmpty {
|
||||
SybilAttachmentListView(
|
||||
attachments: message.attachments,
|
||||
tone: isUser ? .user : .assistant
|
||||
)
|
||||
}
|
||||
|
||||
if isPendingAssistant {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
@@ -108,7 +115,7 @@ private struct MessageBubble: View {
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
} else {
|
||||
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
Markdown(message.content)
|
||||
.tint(SybilTheme.primary)
|
||||
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
|
||||
|
||||
@@ -21,6 +21,132 @@ public enum MessageRole: String, Codable, Hashable, Sendable {
|
||||
case tool
|
||||
}
|
||||
|
||||
public struct ChatAttachment: Codable, Hashable, Identifiable, Sendable {
|
||||
public enum Kind: String, Codable, Hashable, Sendable {
|
||||
case image
|
||||
case text
|
||||
}
|
||||
|
||||
public var id: String
|
||||
public var kind: Kind
|
||||
public var filename: String
|
||||
public var mimeType: String
|
||||
public var sizeBytes: Int
|
||||
public var dataUrl: String?
|
||||
public var text: String?
|
||||
public var truncated: Bool?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
kind: Kind,
|
||||
filename: String,
|
||||
mimeType: String,
|
||||
sizeBytes: Int,
|
||||
dataUrl: String? = nil,
|
||||
text: String? = nil,
|
||||
truncated: Bool? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
self.filename = filename
|
||||
self.mimeType = mimeType
|
||||
self.sizeBytes = sizeBytes
|
||||
self.dataUrl = dataUrl
|
||||
self.text = text
|
||||
self.truncated = truncated
|
||||
}
|
||||
|
||||
public static func image(
|
||||
id: String = UUID().uuidString,
|
||||
filename: String,
|
||||
mimeType: String,
|
||||
sizeBytes: Int,
|
||||
dataUrl: String
|
||||
) -> ChatAttachment {
|
||||
ChatAttachment(
|
||||
id: id,
|
||||
kind: .image,
|
||||
filename: filename,
|
||||
mimeType: mimeType,
|
||||
sizeBytes: sizeBytes,
|
||||
dataUrl: dataUrl
|
||||
)
|
||||
}
|
||||
|
||||
public static func text(
|
||||
id: String = UUID().uuidString,
|
||||
filename: String,
|
||||
mimeType: String,
|
||||
sizeBytes: Int,
|
||||
text: String,
|
||||
truncated: Bool
|
||||
) -> ChatAttachment {
|
||||
ChatAttachment(
|
||||
id: id,
|
||||
kind: .text,
|
||||
filename: filename,
|
||||
mimeType: mimeType,
|
||||
sizeBytes: sizeBytes,
|
||||
text: text,
|
||||
truncated: truncated
|
||||
)
|
||||
}
|
||||
|
||||
var jsonValue: JSONValue {
|
||||
var object: [String: JSONValue] = [
|
||||
"kind": .string(kind.rawValue),
|
||||
"id": .string(id),
|
||||
"filename": .string(filename),
|
||||
"mimeType": .string(mimeType),
|
||||
"sizeBytes": .number(Double(sizeBytes))
|
||||
]
|
||||
|
||||
if let dataUrl {
|
||||
object["dataUrl"] = .string(dataUrl)
|
||||
}
|
||||
if let text {
|
||||
object["text"] = .string(text)
|
||||
}
|
||||
if let truncated {
|
||||
object["truncated"] = .bool(truncated)
|
||||
}
|
||||
|
||||
return .object(object)
|
||||
}
|
||||
|
||||
static func attachments(from metadata: JSONValue?) -> [ChatAttachment] {
|
||||
guard let metadataObject = metadata?.objectValue,
|
||||
let values = metadataObject["attachments"]?.arrayValue
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
return values.compactMap { value in
|
||||
guard let object = value.objectValue,
|
||||
let kindRaw = object["kind"]?.stringValue,
|
||||
let kind = Kind(rawValue: kindRaw),
|
||||
let id = object["id"]?.stringValue,
|
||||
let filename = object["filename"]?.stringValue,
|
||||
let mimeType = object["mimeType"]?.stringValue,
|
||||
let sizeNumber = object["sizeBytes"]?.numberValue
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ChatAttachment(
|
||||
id: id,
|
||||
kind: kind,
|
||||
filename: filename,
|
||||
mimeType: mimeType,
|
||||
sizeBytes: Int(sizeNumber),
|
||||
dataUrl: object["dataUrl"]?.stringValue,
|
||||
text: object["text"]?.stringValue,
|
||||
truncated: object["truncated"]?.boolValue
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
|
||||
public var id: String
|
||||
public var title: String?
|
||||
@@ -48,6 +174,10 @@ public struct Message: Codable, Identifiable, Hashable, Sendable {
|
||||
public var name: String?
|
||||
public var metadata: JSONValue? = nil
|
||||
|
||||
public var attachments: [ChatAttachment] {
|
||||
ChatAttachment.attachments(from: metadata)
|
||||
}
|
||||
|
||||
public var toolCallMetadata: ToolCallMetadata? {
|
||||
guard role == .tool,
|
||||
let object = metadata?.objectValue,
|
||||
@@ -155,6 +285,20 @@ public enum JSONValue: Codable, Hashable, Sendable {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public var arrayValue: [JSONValue]? {
|
||||
if case let .array(value) = self {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public var boolValue: Bool? {
|
||||
if case let .bool(value) = self {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
||||
@@ -239,11 +383,13 @@ public struct CompletionRequestMessage: Codable, Sendable {
|
||||
public var role: MessageRole
|
||||
public var content: String
|
||||
public var name: String?
|
||||
public var attachments: [ChatAttachment]?
|
||||
|
||||
public init(role: MessageRole, content: String, name: String? = nil) {
|
||||
public init(role: MessageRole, content: String, name: String? = nil, attachments: [ChatAttachment]? = nil) {
|
||||
self.role = role
|
||||
self.content = content
|
||||
self.name = name
|
||||
self.attachments = attachments
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ final class SybilViewModel {
|
||||
var errorMessage: String?
|
||||
|
||||
var composer = ""
|
||||
var composerAttachments: [ChatAttachment] = []
|
||||
var provider: Provider
|
||||
var modelCatalog: [Provider: ProviderModelInfo] = [:]
|
||||
var model: String
|
||||
@@ -202,6 +203,19 @@ final class SybilViewModel {
|
||||
return draftKind != nil || selectedItem != nil
|
||||
}
|
||||
|
||||
var canSendComposer: Bool {
|
||||
if isSending {
|
||||
return false
|
||||
}
|
||||
|
||||
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if isSearchMode {
|
||||
return !content.isEmpty
|
||||
}
|
||||
|
||||
return !content.isEmpty || !composerAttachments.isEmpty
|
||||
}
|
||||
|
||||
var displayedMessages: [Message] {
|
||||
let canonical = displayableMessages(selectedChat?.messages ?? [])
|
||||
guard let pending = pendingChatState else {
|
||||
@@ -282,6 +296,7 @@ final class SybilViewModel {
|
||||
authError = nil
|
||||
errorMessage = nil
|
||||
pendingChatState = nil
|
||||
composerAttachments = []
|
||||
settings.persist()
|
||||
|
||||
SybilLog.info(
|
||||
@@ -358,6 +373,7 @@ final class SybilViewModel {
|
||||
selectedSearch = nil
|
||||
errorMessage = nil
|
||||
composer = ""
|
||||
composerAttachments = []
|
||||
}
|
||||
|
||||
func startNewSearch() {
|
||||
@@ -368,6 +384,7 @@ final class SybilViewModel {
|
||||
selectedSearch = nil
|
||||
errorMessage = nil
|
||||
composer = ""
|
||||
composerAttachments = []
|
||||
}
|
||||
|
||||
func openSettings() {
|
||||
@@ -377,6 +394,7 @@ final class SybilViewModel {
|
||||
selectedChat = nil
|
||||
selectedSearch = nil
|
||||
errorMessage = nil
|
||||
composerAttachments = []
|
||||
}
|
||||
|
||||
func select(_ selection: SidebarSelection) {
|
||||
@@ -384,6 +402,9 @@ final class SybilViewModel {
|
||||
draftKind = nil
|
||||
selectedItem = selection
|
||||
errorMessage = nil
|
||||
if case .search = selection {
|
||||
composerAttachments = []
|
||||
}
|
||||
|
||||
if case .settings = selection {
|
||||
selectedChat = nil
|
||||
@@ -430,11 +451,20 @@ final class SybilViewModel {
|
||||
|
||||
func sendComposer() async {
|
||||
let content = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !content.isEmpty, !isSending else {
|
||||
let attachments = composerAttachments
|
||||
|
||||
guard !isSending else {
|
||||
return
|
||||
}
|
||||
|
||||
if isSearchMode {
|
||||
guard !content.isEmpty else { return }
|
||||
} else if content.isEmpty && attachments.isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
composer = ""
|
||||
composerAttachments = []
|
||||
errorMessage = nil
|
||||
isSending = true
|
||||
|
||||
@@ -444,7 +474,7 @@ final class SybilViewModel {
|
||||
try await sendSearch(query: content)
|
||||
} else {
|
||||
SybilLog.info(SybilLog.ui, "Sending chat prompt")
|
||||
try await sendChat(content: content)
|
||||
try await sendChat(content: content, attachments: attachments)
|
||||
}
|
||||
} catch {
|
||||
errorMessage = normalizeAPIError(error)
|
||||
@@ -468,12 +498,38 @@ final class SybilViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
pendingChatState = nil
|
||||
if !isSearchMode {
|
||||
composer = content
|
||||
composerAttachments = attachments
|
||||
pendingChatState = nil
|
||||
}
|
||||
}
|
||||
|
||||
isSending = false
|
||||
}
|
||||
|
||||
func appendComposerAttachments(_ attachments: [ChatAttachment]) throws {
|
||||
guard !attachments.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
guard !isSearchMode else {
|
||||
errorMessage = "Attachments are only available in chat mode."
|
||||
return
|
||||
}
|
||||
|
||||
if composerAttachments.count + attachments.count > SybilChatAttachmentSupport.maxAttachmentsPerMessage {
|
||||
throw ChatAttachmentError.tooManyAttachments(SybilChatAttachmentSupport.maxAttachmentsPerMessage)
|
||||
}
|
||||
|
||||
composerAttachments += attachments
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
func removeComposerAttachment(id: String) {
|
||||
composerAttachments.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
func startChatFromSelectedSearch() async {
|
||||
guard let search = selectedSearch, !isCreatingSearchChat, !isSending else {
|
||||
return
|
||||
@@ -488,6 +544,7 @@ final class SybilViewModel {
|
||||
draftKind = nil
|
||||
pendingChatState = nil
|
||||
composer = ""
|
||||
composerAttachments = []
|
||||
|
||||
chats.removeAll(where: { $0.id == chat.id })
|
||||
chats.insert(chat, at: 0)
|
||||
@@ -668,13 +725,14 @@ final class SybilViewModel {
|
||||
selectedSearch = nil
|
||||
}
|
||||
|
||||
private func sendChat(content: String) async throws {
|
||||
private func sendChat(content: String, attachments: [ChatAttachment]) async throws {
|
||||
let optimisticUser = Message(
|
||||
id: "temp-user-\(UUID().uuidString)",
|
||||
createdAt: Date(),
|
||||
role: .user,
|
||||
content: content,
|
||||
name: nil
|
||||
name: nil,
|
||||
metadata: SybilChatAttachmentSupport.metadataValue(for: attachments)
|
||||
)
|
||||
|
||||
let optimisticAssistant = Message(
|
||||
@@ -740,8 +798,8 @@ final class SybilViewModel {
|
||||
baseChat.messages
|
||||
.filter { !$0.isToolCallLog }
|
||||
.map {
|
||||
CompletionRequestMessage(role: $0.role, content: $0.content, name: $0.name)
|
||||
} + [CompletionRequestMessage(role: .user, content: content)]
|
||||
CompletionRequestMessage(role: $0.role, content: $0.content, name: $0.name, attachments: $0.attachments.isEmpty ? nil : $0.attachments)
|
||||
} + [CompletionRequestMessage(role: .user, content: content, attachments: attachments.isEmpty ? nil : attachments)]
|
||||
|
||||
let streamStatus = CompletionStreamStatus()
|
||||
|
||||
@@ -749,7 +807,8 @@ final class SybilViewModel {
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let updated = try await client.suggestChatTitle(chatID: chatID, content: content)
|
||||
let titleSeed = !content.isEmpty ? content : SybilChatAttachmentSupport.attachmentSummary(attachments)
|
||||
let updated = try await client.suggestChatTitle(chatID: chatID, content: titleSeed.isEmpty ? "Uploaded files" : titleSeed)
|
||||
await MainActor.run {
|
||||
self.chats = self.chats.map { existing in
|
||||
if existing.id == updated.id {
|
||||
@@ -1019,6 +1078,13 @@ final class SybilViewModel {
|
||||
return String(firstUserMessage.prefix(48))
|
||||
}
|
||||
|
||||
if let firstUserMessage = messages?.first(where: { $0.role == .user }) {
|
||||
let attachmentSummary = SybilChatAttachmentSupport.attachmentSummary(firstUserMessage.attachments)
|
||||
if !attachmentSummary.isEmpty {
|
||||
return String(attachmentSummary.prefix(48))
|
||||
}
|
||||
}
|
||||
|
||||
return "New chat"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import Observation
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import UIKit
|
||||
|
||||
struct SybilWorkspaceView: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
@FocusState private var composerFocused: Bool
|
||||
@State private var isShowingAttachmentOptions = false
|
||||
@State private var isShowingFileImporter = false
|
||||
@State private var isShowingPhotoPicker = false
|
||||
@State private var photoPickerItems: [PhotosPickerItem] = []
|
||||
@State private var isComposerDropTargeted = false
|
||||
|
||||
private var isSettingsSelected: Bool {
|
||||
if case .settings = viewModel.selectedItem {
|
||||
@@ -148,54 +156,87 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
|
||||
private var composerBar: some View {
|
||||
HStack(alignment: .bottom, spacing: 10) {
|
||||
TextField(
|
||||
viewModel.isSearchMode ? "Search the web" : "Message Sybil",
|
||||
text: $viewModel.composer,
|
||||
axis: .vertical
|
||||
)
|
||||
.focused($composerFocused)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.autocorrectionDisabled(false)
|
||||
.lineLimit(1 ... 6)
|
||||
.submitLabel(.send)
|
||||
.onSubmit {
|
||||
Task {
|
||||
await viewModel.sendComposer()
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if !viewModel.isSearchMode && !viewModel.composerAttachments.isEmpty {
|
||||
SybilAttachmentListView(
|
||||
attachments: viewModel.composerAttachments,
|
||||
tone: .composer
|
||||
) { attachmentID in
|
||||
viewModel.removeComposerAttachment(id: attachmentID)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(SybilTheme.composerGradient)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(SybilTheme.primary.opacity(0.34), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.sendComposer()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.frame(width: 40, height: 40)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(
|
||||
viewModel.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending
|
||||
? AnyShapeStyle(SybilTheme.surface)
|
||||
: AnyShapeStyle(SybilTheme.primaryGradient)
|
||||
HStack(alignment: .bottom, spacing: 10) {
|
||||
if !viewModel.isSearchMode {
|
||||
Button {
|
||||
isShowingAttachmentOptions = true
|
||||
} label: {
|
||||
Image(systemName: "paperclip")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.frame(width: 40, height: 40)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(SybilTheme.surface)
|
||||
)
|
||||
)
|
||||
.foregroundStyle(viewModel.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending ? SybilTheme.textMuted : SybilTheme.text)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(SybilTheme.border.opacity(0.82), lineWidth: 1)
|
||||
)
|
||||
.foregroundStyle(viewModel.isSending ? SybilTheme.textMuted : SybilTheme.text)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(viewModel.isSending)
|
||||
.accessibilityLabel("Attach file")
|
||||
}
|
||||
|
||||
TextField(
|
||||
viewModel.isSearchMode ? "Search the web" : "Message Sybil",
|
||||
text: $viewModel.composer,
|
||||
axis: .vertical
|
||||
)
|
||||
.focused($composerFocused)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.autocorrectionDisabled(false)
|
||||
.lineLimit(1 ... 6)
|
||||
.submitLabel(.send)
|
||||
.onSubmit {
|
||||
Task {
|
||||
await viewModel.sendComposer()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(SybilTheme.composerGradient)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(SybilTheme.primary.opacity(0.34), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.sendComposer()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.frame(width: 40, height: 40)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(
|
||||
viewModel.canSendComposer
|
||||
? AnyShapeStyle(SybilTheme.primaryGradient)
|
||||
: AnyShapeStyle(SybilTheme.surface)
|
||||
)
|
||||
)
|
||||
.foregroundStyle(viewModel.canSendComposer ? SybilTheme.text : SybilTheme.textMuted)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!viewModel.canSendComposer)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(viewModel.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
@@ -209,5 +250,151 @@ struct SybilWorkspaceView: View {
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.overlay {
|
||||
if isComposerDropTargeted && !viewModel.isSearchMode {
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.stroke(SybilTheme.accent.opacity(0.78), style: StrokeStyle(lineWidth: 1.5, dash: [7, 5]))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
.onDrop(of: [UTType.fileURL.identifier, UTType.image.identifier], isTargeted: $isComposerDropTargeted) { providers in
|
||||
if viewModel.isSearchMode || viewModel.isSending {
|
||||
return false
|
||||
}
|
||||
|
||||
Task {
|
||||
await importAttachmentsFromItemProviders(providers)
|
||||
}
|
||||
return true
|
||||
}
|
||||
.confirmationDialog("Add attachment", isPresented: $isShowingAttachmentOptions, titleVisibility: .visible) {
|
||||
Button("Photo Library") {
|
||||
isShowingPhotoPicker = true
|
||||
}
|
||||
Button("Files") {
|
||||
isShowingFileImporter = true
|
||||
}
|
||||
if canPasteFromClipboard {
|
||||
Button("Paste from Clipboard") {
|
||||
Task {
|
||||
await pasteAttachmentsFromClipboard()
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
.photosPicker(
|
||||
isPresented: $isShowingPhotoPicker,
|
||||
selection: $photoPickerItems,
|
||||
maxSelectionCount: max(1, SybilChatAttachmentSupport.maxAttachmentsPerMessage - viewModel.composerAttachments.count),
|
||||
matching: .images
|
||||
)
|
||||
.fileImporter(
|
||||
isPresented: $isShowingFileImporter,
|
||||
allowedContentTypes: [.item],
|
||||
allowsMultipleSelection: true
|
||||
) { result in
|
||||
Task {
|
||||
do {
|
||||
let urls = try result.get()
|
||||
let attachments = try SybilChatAttachmentSupport.buildAttachments(from: urls)
|
||||
try await MainActor.run {
|
||||
try viewModel.appendComposerAttachments(attachments)
|
||||
}
|
||||
composerFocused = true
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
viewModel.errorMessage = error.localizedDescription
|
||||
}
|
||||
SybilLog.error(SybilLog.ui, "File import failed", error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: photoPickerItems) { _, items in
|
||||
guard !items.isEmpty else { return }
|
||||
Task {
|
||||
do {
|
||||
let attachments = try await loadAttachmentsFromPhotoPickerItems(items)
|
||||
try await MainActor.run {
|
||||
try viewModel.appendComposerAttachments(attachments)
|
||||
photoPickerItems = []
|
||||
}
|
||||
composerFocused = true
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
viewModel.errorMessage = error.localizedDescription
|
||||
photoPickerItems = []
|
||||
}
|
||||
SybilLog.error(SybilLog.ui, "Photo import failed", error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var canPasteFromClipboard: Bool {
|
||||
let pasteboard = UIPasteboard.general
|
||||
if pasteboard.hasImages {
|
||||
return true
|
||||
}
|
||||
if let url = pasteboard.url, url.isFileURL {
|
||||
return true
|
||||
}
|
||||
if let string = pasteboard.string, !string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func importAttachmentsFromItemProviders(_ providers: [NSItemProvider]) async {
|
||||
do {
|
||||
let attachments = try await SybilChatAttachmentSupport.buildAttachments(from: providers)
|
||||
try viewModel.appendComposerAttachments(attachments)
|
||||
composerFocused = true
|
||||
} catch {
|
||||
viewModel.errorMessage = error.localizedDescription
|
||||
SybilLog.error(SybilLog.ui, "Clipboard/drop attachment import failed", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadAttachmentsFromPhotoPickerItems(_ items: [PhotosPickerItem]) async throws -> [ChatAttachment] {
|
||||
var attachments: [ChatAttachment] = []
|
||||
|
||||
for item in items {
|
||||
guard let data = try await item.loadTransferable(type: Data.self) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let contentType = item.supportedContentTypes.first(where: { $0.conforms(to: .image) })
|
||||
let filename = contentType?.preferredFilenameExtension.map { "photo.\($0)" } ?? "photo.jpg"
|
||||
attachments.append(try SybilChatAttachmentSupport.buildImageAttachment(data: data, filename: filename, contentType: contentType))
|
||||
}
|
||||
|
||||
return attachments
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func pasteAttachmentsFromClipboard() async {
|
||||
do {
|
||||
let pasteboard = UIPasteboard.general
|
||||
var attachments: [ChatAttachment] = []
|
||||
|
||||
if let image = pasteboard.image {
|
||||
attachments.append(try SybilChatAttachmentSupport.buildImageAttachment(image: image))
|
||||
}
|
||||
|
||||
if let url = pasteboard.url, url.isFileURL {
|
||||
attachments.append(contentsOf: try SybilChatAttachmentSupport.buildAttachments(from: [url]))
|
||||
} else if let text = pasteboard.string?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
|
||||
attachments.append(try SybilChatAttachmentSupport.buildTextAttachment(text: text))
|
||||
}
|
||||
|
||||
try viewModel.appendComposerAttachments(attachments)
|
||||
composerFocused = true
|
||||
} catch {
|
||||
viewModel.errorMessage = error.localizedDescription
|
||||
SybilLog.error(SybilLog.ui, "Clipboard attachment import failed", error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user