Files
Sybil-2/ios/Packages/Sybil/Sources/Sybil/SybilAgentAttachmentViews.swift
T

254 lines
14 KiB
Swift

import Foundation
import ImageIO
import PhotosUI
import SwiftUI
import UniformTypeIdentifiers
import UIKit
// FileTransferRepresentation keeps Photo Library imports file-backed and bounded while reading.
// PhotosPicker does not expose the original PHAsset filename without broader library access.
struct AgentPickedPhoto: Transferable, Sendable {
var data: Data
var filename: String?
var mimeType: String?
static var transferRepresentation: some TransferRepresentation {
FileRepresentation(importedContentType: .image) { received in
let url = received.file
let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? Int.max
guard size <= 32 * 1024 * 1024 else { throw AgentAttachmentIssue.local("This photo is too large to prepare. Select its original in Files.", code: "attachment_limit_exceeded") }
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
var data = Data()
while let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty {
try Task.checkCancellation()
guard data.count + chunk.count <= 32 * 1024 * 1024 else {
throw AgentAttachmentIssue.local("This photo is too large to prepare. Select its original in Files.", code: "attachment_limit_exceeded")
}
data.append(chunk)
}
let type = CGImageSourceCreateWithData(data as CFData, nil).flatMap { CGImageSourceGetType($0) }.flatMap { UTType($0 as String) }
return Self(data: data, filename: url.lastPathComponent.isEmpty ? nil : url.lastPathComponent, mimeType: type?.preferredMIMEType)
}
}
}
struct AgentAttachmentComposer<Prompt: View>: View {
@Bindable var room: SybilAgentRoom
@ViewBuilder var prompt: () -> Prompt
@State private var modalID = UUID()
@State private var pickerSession: UUID?
@State private var showChoices = false
@State private var showFiles = false
@State private var showPhotos = false
@State private var photos: [PhotosPickerItem] = []
private var modal: Bool { showChoices || showFiles || showPhotos }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if !room.attachmentDraft.items.isEmpty {
ScrollView(.horizontal) {
HStack(alignment: .top, spacing: 8) {
ForEach(room.attachmentDraft.items) { item in
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(item.filename).lineLimit(1)
Button { room.attachmentDraft.remove(item.id) } label: { Image(systemName: "xmark.circle") }
.accessibilityLabel("Remove \(item.filename)")
}
if let attachment = item.attachment {
AgentAttachmentDetails(attachment: attachment)
} else {
Text(item.state == .uploading ? "Uploading…" : item.state == .loading ? "Loading…" : "Upload failed")
.foregroundStyle(SybilTheme.textMuted)
if item.state == .uploading || item.state == .loading { ProgressView().controlSize(.small) }
}
if let issue = item.issue {
Text(issue.localizedDescription).foregroundStyle(SybilTheme.danger).fixedSize(horizontal: false, vertical: true)
Button("Retry upload") { room.attachmentDraft.retry(item.id) }
.accessibilityIdentifier("agent-upload-retry")
}
}
.font(.sybil(.caption)).padding(10).frame(width: 260, alignment: .leading)
.background(SybilTheme.surfaceStrong, in: RoundedRectangle(cornerRadius: 12))
}
}
}.frame(maxHeight: 190)
}
if let error = room.attachmentDraft.error {
Text(error).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger)
}
if !room.attachmentDraft.supportsAttachments && !room.attachmentDraft.items.isEmpty {
Text(AgentAttachmentIssue.unavailable.localizedDescription).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger)
}
HStack(alignment: .bottom, spacing: 10) {
if room.attachmentDraft.supportsAttachments {
SybilPromptAttachmentButton(
isEnabled: room.attachmentDraft.items.count < (room.attachmentDraft.capability?.countLimit ?? 0)
) {
pickerSession = room.attachmentSessionID
showChoices = true
}
.accessibilityIdentifier("agent-attach")
}
prompt()
}
}
.confirmationDialog("Add attachment", isPresented: $showChoices) {
Button("Photo Library") { showPhotos = true }
Button("Files") { showFiles = true }
Button("Cancel", role: .cancel) {}
}
.photosPicker(isPresented: $showPhotos, selection: $photos,
maxSelectionCount: max(1, (room.attachmentDraft.capability?.countLimit ?? 1) - room.attachmentDraft.items.count),
selectionBehavior: .ordered, matching: .images)
.fileImporter(isPresented: $showFiles, allowedContentTypes: [.item], allowsMultipleSelection: true) { result in
guard pickerSession == room.attachmentSessionID, room.isSelected, let limits = room.attachmentDraft.capability else { return }
switch result {
case let .success(urls):
for url in urls {
room.attachmentDraft.add(filename: url.lastPathComponent) {
try await AgentAttachmentPreparation.offMain { try AgentAttachmentPreparation.file(url, limits: limits) }
}
}
case let .failure(error):
if (error as NSError).code != NSUserCancelledError {
room.attachmentDraft.add(filename: "File selection") { throw AgentAttachmentIssue.wrapping(error) }
}
}
}
.onChange(of: photos) { _, selected in
guard pickerSession == room.attachmentSessionID, room.isSelected, let limits = room.attachmentDraft.capability else { photos = []; return }
for item in selected {
room.attachmentDraft.add(filename: "Photo") {
guard let photo = try await item.loadTransferable(type: AgentPickedPhoto.self) else {
throw AgentAttachmentIssue.local("The selected photo could not be loaded.")
}
return try await AgentAttachmentPreparation.offMain {
try AgentAttachmentPreparation.photo(photo.data, filename: photo.filename, mimeType: photo.mimeType, limits: limits)
}
}
}
photos = []
}
.onChange(of: room.attachmentSessionID) { _, _ in showChoices = false; showFiles = false; showPhotos = false; photos = []; pickerSession = nil }
.onChange(of: modal) { _, value in room.setAttachmentModal(modalID, presented: value) }
.onDisappear { room.setAttachmentModal(modalID, presented: false) }
}
}
struct AgentAttachmentDetails: View {
var attachment: AgentAttachment
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text("\(attachment.mimeType) · \(ByteCountFormatter.string(fromByteCount: Int64(attachment.sizeBytes), countStyle: .file))")
.foregroundStyle(SybilTheme.textMuted)
if let path = attachment.workspacePath {
Button {
UIPasteboard.general.string = path
} label: {
Label(path, systemImage: "doc.on.doc").lineLimit(2).multilineTextAlignment(.leading)
}
.buttonStyle(.plain).accessibilityLabel("Copy workspace path: \(path)")
}
}.font(.sybil(.caption2))
}
}
struct AgentAttachmentCard: View {
var attachment: AgentAttachment
var room: SybilAgentRoom
@Environment(\.scenePhase) private var scenePhase
@State private var image: UIImage?
@State private var text: String?
@State private var issue: String?
@State private var loading = false
@State private var wantsDownload = false
@State private var revision = 0
@State private var exportURL: URL?
@State private var showShare = false
@State private var modalID = UUID()
private var loadKey: String { "\(room.attachmentSessionID):\(attachment.id):\(attachment.sha256):\(revision):\(scenePhase == .active)" }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(attachment.filename).font(.sybil(.footnote, weight: .medium)).lineLimit(2)
AgentAttachmentDetails(attachment: attachment)
if attachment.kind == .image {
ZStack {
SybilTheme.surface
if let image {
Image(uiImage: image).resizable().scaledToFit()
.accessibilityLabel("Image: \(attachment.filename)")
.accessibilityIdentifier("agent-image-preview")
}
else { Image(systemName: "photo").foregroundStyle(SybilTheme.textMuted) }
}.frame(height: 180).clipped()
.accessibilityElement(children: .contain)
.accessibilityIdentifier("agent-image-frame")
}
if let text {
Text(text).font(.system(.caption, design: .monospaced)).lineLimit(6)
.frame(height: 100, alignment: .topLeading).textSelection(.enabled)
}
if loading { ProgressView("Loading original…").font(.sybil(.caption)) }
if let issue { Text(issue).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger) }
HStack {
if exportURL != nil {
Button { showShare = true } label: { Label("Save or share original", systemImage: "square.and.arrow.up") }
} else if !loading {
Button(issue == nil ? "Download original" : "Retry download") { wantsDownload = true; revision += 1 }
.accessibilityIdentifier("agent-download")
}
if loading { Button("Cancel") { wantsDownload = false; revision += 1 } }
}.font(.sybil(.caption))
}
.padding(12).frame(maxWidth: 340, alignment: .leading)
.background(SybilTheme.surfaceStrong, in: RoundedRectangle(cornerRadius: 12))
.task(id: loadKey) {
// Each view owns its transfer; configuration and foreground changes cancel it.
guard scenePhase == .active, wantsDownload || (attachment.kind == .image && revision == 0) else { loading = false; return }
loading = true; issue = nil
let session = room.attachmentSessionID
do {
let data = try await room.downloadAttachment(attachment)
try Task.checkCancellation()
guard session == room.attachmentSessionID else { return }
if attachment.kind == .image {
let bitmap = try await AgentAttachmentPreparation.offMain { try AgentAttachmentPreparation.raster(data, mime: attachment.mimeType) }
try Task.checkCancellation()
image = UIImage(cgImage: bitmap)
} else if attachment.kind == .text, let decoded = String(data: data, encoding: .utf8), !decoded.contains("\u{0000}") {
text = String(decoded.prefix(1000)) // Plain text only, including HTML/SVG.
}
if wantsDownload {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("AgentOriginal-" + UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let destination = directory.appendingPathComponent(try AgentAttachmentPreparation.filename(attachment.filename))
try data.write(to: destination, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
exportURL = destination
}
loading = false
} catch {
if !Task.isCancelled { issue = error.localizedDescription; loading = false }
}
}
.sheet(isPresented: $showShare) {
if let exportURL { AgentOriginalShare(url: exportURL) }
}
.onChange(of: showShare) { _, value in room.setAttachmentModal(modalID, presented: value) }
.onChange(of: room.attachmentSessionID) { _, _ in showShare = false; reset() }
.onDisappear { room.setAttachmentModal(modalID, presented: false); reset() }
}
private func reset() {
image = nil; text = nil
if let exportURL { try? FileManager.default.removeItem(at: exportURL.deletingLastPathComponent()) }
exportURL = nil
}
}
private struct AgentOriginalShare: UIViewControllerRepresentable {
var url: URL
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: [url], applicationActivities: nil)
}
func updateUIViewController(_ controller: UIActivityViewController, context: Context) {}
}