mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 13:31:16 +00:00
* feat(ios): unify chat and voice experience * refactor(ios): finish unified chat ownership * fix(ios): unify chat and voice capture ownership * fix(ios): serialize Talk permission hydration * fix(ios): clear voice CI regressions * fix(ios): clear remaining unified chat CI failures * chore(i18n): sync native source inventory * refactor(ios): defer provider-only realtime voice * fix(ios): clean unified chat voice PR * fix(ios): localize unified voice controls * fix(ios): serialize composer audio controls * fix(ios): isolate dictation preparation lifecycle * fix(ios): key chat tab icons by appearance * fix(ios): preserve talk upgrade handshakes * fix(ios): refresh native i18n inventory * fix(ios): reconcile shared locale artifacts * fix(ios): satisfy chat view lint limit * fix(ios): reconcile shared composer implementation * test(apple): avoid XCTest actor deinit crash * fix(ios): satisfy SwiftFormat scope spacing * fix(ios): remove duplicate merged helper declarations
237 lines
7.9 KiB
Swift
237 lines
7.9 KiB
Swift
import Foundation
|
|
import OpenClawKit
|
|
import UniformTypeIdentifiers
|
|
|
|
#if canImport(AppKit)
|
|
import AppKit
|
|
#elseif canImport(UIKit)
|
|
import UIKit
|
|
#endif
|
|
|
|
extension OpenClawChatViewModel {
|
|
public func addAttachments(urls: [URL]) {
|
|
self.beginAttachmentStaging()
|
|
Task {
|
|
defer { self.endAttachmentStaging() }
|
|
await self.loadAttachments(urls: urls)
|
|
}
|
|
}
|
|
|
|
func addAttachments(urls: [URL], for session: SessionSnapshot) {
|
|
guard self.isCurrentSession(session) else { return }
|
|
self.beginAttachmentStaging()
|
|
Task {
|
|
defer { self.endAttachmentStaging() }
|
|
await self.loadAttachments(urls: urls, expectedSession: session)
|
|
}
|
|
}
|
|
|
|
public func addImageAttachment(data: Data, fileName: String, mimeType: String) {
|
|
self.beginAttachmentStaging()
|
|
Task {
|
|
defer { self.endAttachmentStaging() }
|
|
await self.addImageAttachment(url: nil, data: data, fileName: fileName, mimeType: mimeType)
|
|
}
|
|
}
|
|
|
|
func addImageAttachment(
|
|
data: Data,
|
|
fileName: String,
|
|
mimeType: String,
|
|
for session: SessionSnapshot) async
|
|
{
|
|
guard self.isCurrentSession(session) else { return }
|
|
self.beginAttachmentStaging()
|
|
defer { self.endAttachmentStaging() }
|
|
await self.addImageAttachment(
|
|
url: nil,
|
|
data: data,
|
|
fileName: fileName,
|
|
mimeType: mimeType,
|
|
expectedSession: session)
|
|
}
|
|
|
|
public func removeAttachment(_ id: OpenClawPendingAttachment.ID) {
|
|
attachments.removeAll { $0.id == id }
|
|
applyDeferredExternalStateIfReady()
|
|
}
|
|
|
|
/// True while replacing this model could move an attachment across chats.
|
|
public var isAttachmentOwnerPinned: Bool {
|
|
self.blocksAttachmentOwnerChange
|
|
}
|
|
|
|
var blocksAttachmentOwnerChange: Bool {
|
|
attachmentOwnerIsActive() ||
|
|
isSendingAttachmentDraft ||
|
|
attachmentStagingCount > 0 ||
|
|
!attachments.isEmpty
|
|
}
|
|
|
|
func canCreateSessionForImmediateSwitch() -> Bool {
|
|
guard !self.blocksAttachmentOwnerChange else {
|
|
self.errorText = String(
|
|
localized: "Remove attachments or wait for delivery to resolve before starting a new chat.")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
/// Applies external owner changes once recording or staging releases them.
|
|
public func attachmentOwnerActivityChanged() {
|
|
applyDeferredExternalStateIfReady()
|
|
}
|
|
|
|
/// File reads and image processing suspend before the attachment exists.
|
|
/// Keep their original chat owner pinned until staging succeeds or fails.
|
|
func beginAttachmentStaging() {
|
|
attachmentStagingCount += 1
|
|
}
|
|
|
|
func endAttachmentStaging() {
|
|
precondition(attachmentStagingCount > 0)
|
|
attachmentStagingCount -= 1
|
|
applyDeferredExternalStateIfReady()
|
|
}
|
|
|
|
/// Stages a recorded m4a voice note and removes its temporary file.
|
|
public func addVoiceNoteAttachment(fileURL: URL, durationSeconds: Double) async {
|
|
self.beginAttachmentStaging()
|
|
defer {
|
|
try? FileManager.default.removeItem(at: fileURL)
|
|
self.endAttachmentStaging()
|
|
}
|
|
|
|
let data: Data
|
|
do {
|
|
data = try await Task.detached(priority: .userInitiated) {
|
|
try Data(contentsOf: fileURL)
|
|
}.value
|
|
} catch {
|
|
errorText = String(
|
|
format: String(localized: "Could not attach voice note: %@"),
|
|
error.localizedDescription)
|
|
return
|
|
}
|
|
|
|
guard data.count <= Self.maxAttachmentBytes else {
|
|
errorText = String(localized: "Voice note exceeds the 5 MB attachment limit")
|
|
return
|
|
}
|
|
|
|
let normalizedDuration = durationSeconds.isFinite
|
|
? min(max(0, durationSeconds), OpenClawVoiceNoteRecorder.maximumDurationSeconds)
|
|
: 0
|
|
attachments.append(
|
|
OpenClawPendingAttachment(
|
|
url: nil,
|
|
data: data,
|
|
fileName: fileURL.lastPathComponent,
|
|
mimeType: "audio/mp4",
|
|
preview: nil,
|
|
durationSeconds: normalizedDuration))
|
|
}
|
|
|
|
func loadAttachments(urls: [URL], expectedSession: SessionSnapshot? = nil) async {
|
|
for url in urls {
|
|
guard self.ownsAttachmentSession(expectedSession) else { return }
|
|
let hasSecurityScope = url.startAccessingSecurityScopedResource()
|
|
defer {
|
|
if hasSecurityScope {
|
|
url.stopAccessingSecurityScopedResource()
|
|
}
|
|
}
|
|
do {
|
|
let data = try await Task.detached { try Data(contentsOf: url) }.value
|
|
await self.addImageAttachment(
|
|
url: url,
|
|
data: data,
|
|
fileName: url.lastPathComponent,
|
|
mimeType: Self.mimeType(for: url) ?? "application/octet-stream",
|
|
expectedSession: expectedSession)
|
|
} catch {
|
|
guard self.ownsAttachmentSession(expectedSession) else { return }
|
|
self.errorText = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
static func mimeType(for url: URL) -> String? {
|
|
let ext = url.pathExtension
|
|
guard !ext.isEmpty else { return nil }
|
|
return (UTType(filenameExtension: ext) ?? .data).preferredMIMEType
|
|
}
|
|
|
|
func addImageAttachment(
|
|
url: URL?,
|
|
data: Data,
|
|
fileName: String,
|
|
mimeType: String,
|
|
expectedSession: SessionSnapshot? = nil) async
|
|
{
|
|
guard self.ownsAttachmentSession(expectedSession) else { return }
|
|
let uti: UTType = {
|
|
if let url {
|
|
return UTType(filenameExtension: url.pathExtension) ?? .data
|
|
}
|
|
return UTType(mimeType: mimeType) ?? .data
|
|
}()
|
|
guard uti.conforms(to: .image) else {
|
|
errorText = String(localized: "Only image attachments are supported right now")
|
|
return
|
|
}
|
|
|
|
let processed: Data
|
|
do {
|
|
processed = try await Task.detached(priority: .userInitiated) {
|
|
try ChatImageProcessor.processForUpload(data: data)
|
|
}.value
|
|
} catch {
|
|
guard self.ownsAttachmentSession(expectedSession) else { return }
|
|
errorText = String(
|
|
format: String(localized: "Could not process %1$@: %2$@"),
|
|
fileName,
|
|
error.localizedDescription)
|
|
return
|
|
}
|
|
|
|
// Image processing runs off actor. Revalidate the draft owner before
|
|
// publishing either the attachment or any session-scoped error state.
|
|
guard self.ownsAttachmentSession(expectedSession) else { return }
|
|
if processed.count > Self.maxAttachmentBytes {
|
|
errorText = String(
|
|
format: String(localized: "Attachment %@ exceeds 5 MB limit after resizing"),
|
|
fileName)
|
|
return
|
|
}
|
|
|
|
let outputFileName: String = {
|
|
let baseName = (fileName as NSString).deletingPathExtension
|
|
return baseName.isEmpty ? "image.jpg" : "\(baseName).jpg"
|
|
}()
|
|
|
|
let preview = Self.previewImage(data: processed)
|
|
attachments.append(
|
|
OpenClawPendingAttachment(
|
|
url: url,
|
|
data: processed,
|
|
fileName: outputFileName,
|
|
mimeType: "image/jpeg",
|
|
preview: preview))
|
|
}
|
|
|
|
private func ownsAttachmentSession(_ expectedSession: SessionSnapshot?) -> Bool {
|
|
expectedSession.map(self.isCurrentSession) ?? true
|
|
}
|
|
|
|
static func previewImage(data: Data) -> OpenClawPlatformImage? {
|
|
#if canImport(AppKit)
|
|
NSImage(data: data)
|
|
#elseif canImport(UIKit)
|
|
UIImage(data: data)
|
|
#else
|
|
nil
|
|
#endif
|
|
}
|
|
}
|