From 01ee8079913092d356cea651d2fe33fd6df91072 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 2 May 2026 19:47:38 -0700 Subject: [PATCH] ios: adds file uploading --- ios/Apps/Sybil/project.yml | 4 +- ios/Packages/Sybil/Package.resolved | 33 ++ .../Sources/Sybil/SybilAttachmentViews.swift | 222 +++++++++++ .../Sybil/SybilChatAttachmentSupport.swift | 354 ++++++++++++++++++ .../Sybil/SybilChatTranscriptView.swift | 9 +- .../Sybil/Sources/Sybil/SybilModels.swift | 148 +++++++- .../Sybil/Sources/Sybil/SybilViewModel.swift | 82 +++- .../Sources/Sybil/SybilWorkspaceView.swift | 273 +++++++++++--- 8 files changed, 1070 insertions(+), 55 deletions(-) create mode 100644 ios/Packages/Sybil/Package.resolved create mode 100644 ios/Packages/Sybil/Sources/Sybil/SybilAttachmentViews.swift create mode 100644 ios/Packages/Sybil/Sources/Sybil/SybilChatAttachmentSupport.swift diff --git a/ios/Apps/Sybil/project.yml b/ios/Apps/Sybil/project.yml index 7e1ded3..80e5b2c 100644 --- a/ios/Apps/Sybil/project.yml +++ b/ios/Apps/Sybil/project.yml @@ -19,8 +19,8 @@ targets: TARGETED_DEVICE_FAMILY: "1,2" GENERATE_INFOPLIST_FILE: YES ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - MARKETING_VERSION: 1.2 - CURRENT_PROJECT_VERSION: 3 + MARKETING_VERSION: 1.3 + CURRENT_PROJECT_VERSION: 4 INFOPLIST_KEY_CFBundleDisplayName: Sybil INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES diff --git a/ios/Packages/Sybil/Package.resolved b/ios/Packages/Sybil/Package.resolved new file mode 100644 index 0000000..87d2d59 --- /dev/null +++ b/ios/Packages/Sybil/Package.resolved @@ -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 +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAttachmentViews.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAttachmentViews.swift new file mode 100644 index 0000000..5e2e4e0 --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAttachmentViews.swift @@ -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) + } + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilChatAttachmentSupport.swift b/ios/Packages/Sybil/Sources/Sybil/SybilChatAttachmentSupport.swift new file mode 100644 index 0000000..35a6e2a --- /dev/null +++ b/ios/Packages/Sybil/Sources/Sybil/SybilChatAttachmentSupport.swift @@ -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 = [ + "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) + } + } + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift index d8f342b..8c78d6d 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift @@ -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)) diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift index aabe319..05645f9 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift @@ -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 } } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift index b46dd8d..e1bf687 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift @@ -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" } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift index 9f6b3a2..dafb7a3 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift @@ -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) + } } }