feat(ios): support agent-displayed video, audio and files

This commit is contained in:
weishu
2026-11-01 12:26:32 +08:00
parent 59dcddf32b
commit f4d8faebea
10 changed files with 767 additions and 113 deletions
@@ -1,28 +1,49 @@
import AVKit
import Combine
import HapiProtocol
import QuickLook
import SwiftUI
import UIKit
/// Hub-generated image (`/api/sessions/:id/generated-images/:imageId`),
/// loaded as authed bytes through the chat's `GeneratedImageLoader`
/// (`\.chatMedia`; URLCache underneath honors the hub's immutable ETag).
/// Tap opens a simple full-screen viewer. Without a loader (previews/tests)
/// the card degrades to a filename placeholder.
/// The wire kind stays `generated-image` for all media. MIME, not the tool
/// name or filename, selects the renderer. Reset card state on row reuse.
struct GeneratedImageBlockView: View {
let block: GeneratedImageBlock
@Environment(\.chatMedia) private var media
@State private var image: UIImage?
@State private var failed = false
@State private var viewerOpen = false
var body: some View {
Group {
if media == nil || failed {
placeholder
} else if let image = image ?? media?.cachedImage(for: block.imageId) {
Button {
viewerOpen = true
} label: {
GeneratedMediaCard(block: block, loader: media)
.id(block.imageId)
}
}
private struct GeneratedMediaCard: View {
let loader: GeneratedMediaLoader?
@State private var content: GeneratedMediaPresentation
@State private var loadAttempt = 0
@State private var imageOpen = false
@State private var previewOpen = false
@State private var shareOpen = false
@Environment(\.scenePhase) private var scenePhase
init(block: GeneratedImageBlock, loader: GeneratedMediaLoader?) {
self.loader = loader
_content = State(initialValue: GeneratedMediaPresentation(block: block))
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Label {
Text(verbatim: content.block.fileName)
.lineLimit(2)
} icon: {
Image(systemName: content.kind.icon)
}
.font(.caption)
.foregroundStyle(.secondary)
if let image = content.image {
Button { imageOpen = true } label: {
Image(uiImage: image)
.resizable()
.scaledToFit()
@@ -30,46 +51,138 @@ struct GeneratedImageBlockView: View {
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
.buttonStyle(.plain)
} else {
loadingFrame
}
}
.task(id: block.imageId) {
guard let media, image == nil else { return }
if let loaded = await media.image(for: block.imageId) {
image = loaded
} else {
failed = true
}
}
.fullScreenCover(isPresented: $viewerOpen) {
ImageViewer(image: image, title: block.fileName)
}
}
private var placeholder: some View {
Label(block.fileName, systemImage: "photo")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.vertical, 4)
}
private var loadingFrame: some View {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.secondary.opacity(0.1))
.frame(height: 160)
.overlay {
.accessibilityLabel(content.block.fileName)
} else if let file = content.file {
loadedFile(file)
} else if content.loadFailed {
Text("Couldn't load media.")
.font(.caption)
.foregroundStyle(.secondary)
Button("Retry", systemImage: "arrow.clockwise") { loadAttempt += 1 }
} else if loader != nil && (content.kind == .image || content.isLoading) {
ProgressView()
.frame(maxWidth: .infinity, minHeight: 120)
} else if content.kind != .image {
loadButton
.disabled(loader == nil)
}
}
.padding(12)
.background(Color(uiColor: .secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
.task(id: loadAttempt) {
guard let loader, content.kind == .image || loadAttempt > 0 else { return }
await content.load(using: loader)
}
.onDisappear { content.pause() }
.onChange(of: scenePhase) { _, phase in
if phase != .active { content.pause() }
}
.fullScreenCover(isPresented: $imageOpen) {
ImageViewer(image: content.image, title: content.block.fileName)
}
.sheet(isPresented: $previewOpen) {
if let file = content.file {
NavigationStack {
GeneratedMediaPreview(file: file)
.navigationTitle(content.block.fileName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Close") { previewOpen = false }
}
}
}
}
}
.sheet(isPresented: $shareOpen) {
if let file = content.file { GeneratedMediaShareSheet(file: file) }
}
}
@ViewBuilder
private var loadButton: some View {
switch content.kind {
case .video:
Button("Load video", systemImage: "play.circle") { loadAttempt += 1 }
case .audio:
Button("Load audio", systemImage: "play.circle") { loadAttempt += 1 }
case .file:
Button("Prepare download", systemImage: "arrow.down.circle") { loadAttempt += 1 }
case .image:
EmptyView()
}
}
@ViewBuilder
private func loadedFile(_ file: GeneratedMediaFile) -> some View {
if let player = content.player, let item = player.currentItem {
VideoPlayer(player: player)
.frame(height: content.kind == .audio ? 100 : 220)
.clipShape(RoundedRectangle(cornerRadius: 12))
.onReceive(item.publisher(for: \.status)) { status in
if status == .failed { content.playbackFailed() }
}
}
if content.playbackUnavailable {
Text("This media cannot be played on this device.")
.font(.caption)
.foregroundStyle(.secondary)
}
ViewThatFits(in: .horizontal) {
HStack(spacing: 16) { fileActions(file) }
VStack(alignment: .leading, spacing: 12) { fileActions(file) }
}
}
@ViewBuilder
private func fileActions(_ file: GeneratedMediaFile) -> some View {
if content.kind == .file, QLPreviewController.canPreview(file.url as NSURL) {
Button("Preview", systemImage: "doc.text.magnifyingglass") { previewOpen = true }
}
Button("Save or share", systemImage: "square.and.arrow.up") { shareOpen = true }
}
}
/// Minimal full-screen viewer: dark backdrop, fit-scaled image, tap or the
/// close button dismisses. (Pinch-zoom lands with the files feature.)
private struct GeneratedMediaShareSheet: UIViewControllerRepresentable {
let file: GeneratedMediaFile
func makeUIViewController(context: Context) -> UIActivityViewController {
let controller = UIActivityViewController(activityItems: [file.url], applicationActivities: nil)
// Supply an anchor when UIKit presents the activity UI as an iPad popover.
controller.popoverPresentationController?.sourceView = controller.view
// The activity controller may outlive the transcript row/cache entry.
controller.completionWithItemsHandler = { [file] _, _, _, _ in _ = file }
return controller
}
func updateUIViewController(_ controller: UIActivityViewController, context: Context) {}
}
private struct GeneratedMediaPreview: UIViewControllerRepresentable {
let file: GeneratedMediaFile
final class Coordinator: NSObject, QLPreviewControllerDataSource {
let file: GeneratedMediaFile
init(file: GeneratedMediaFile) { self.file = file }
func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 }
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> any QLPreviewItem {
file.url as NSURL
}
}
func makeCoordinator() -> Coordinator { Coordinator(file: file) }
func makeUIViewController(context: Context) -> QLPreviewController {
let controller = QLPreviewController()
controller.dataSource = context.coordinator
return controller
}
func updateUIViewController(_ controller: QLPreviewController, context: Context) {}
}
/// Minimal full-screen image viewer; keep the existing tap-to-dismiss behavior.
private struct ImageViewer: View {
let image: UIImage?
let title: String
@Environment(\.dismiss) private var dismiss
var body: some View {
@@ -82,20 +195,16 @@ private struct ImageViewer: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(8)
} else {
Text(title)
.foregroundStyle(.white)
Text(verbatim: title).foregroundStyle(.white)
}
Button {
dismiss()
} label: {
Button { dismiss() } label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.white.opacity(0.8))
.padding(16)
}
.accessibilityLabel("Close")
}
.onTapGesture {
dismiss()
}
.onTapGesture { dismiss() }
}
}