254 lines
14 KiB
Swift
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) {}
|
|
}
|