Files
Termind/TFI/Features/AI/MobileAssistantComposer.swift
T

718 lines
28 KiB
Swift

import AVFoundation
import AgentKit
import PhotosUI
import SwiftUI
import TermindApplication
import TermindInfrastructure
import TermindPresentation
private struct MobileAssistantInteractionSurface: ViewModifier {
func body(content: Content) -> some View {
content
.padding(12)
.background {
// Keep the material in its own view. Applying it as a background style
// gives descendants a vibrant foreground that can hide secondary text.
RoundedRectangle(cornerRadius: 26, style: .continuous).fill(.regularMaterial)
.ignoresSafeArea(.keyboard)
}
.overlay {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.stroke(.primary.opacity(0.08), lineWidth: 1)
.allowsHitTesting(false)
}
.shadow(color: .black.opacity(0.08), radius: 16, y: 6)
}
}
extension View {
func mobileAssistantInteractionSurface() -> some View {
modifier(MobileAssistantInteractionSurface())
}
}
private struct MobileDismissibleComposerBadge: View {
let symbol: String
let help: Text
let accessibilityLabel: Text
let action: () -> Void
@State private var isHovering = false
var body: some View {
Button(action: action) {
Image(systemName: isHovering ? "xmark" : symbol)
.contentTransition(.symbolEffect(.replace))
.mobileComposerControlIcon(opacity: 0.62)
}
.buttonStyle(.plain)
.onHover { isHovering = $0 }
.help(help)
.accessibilityLabel(accessibilityLabel)
}
}
struct MobileAssistantComposer: View {
let pro: ProAccessController
let model: MobileAssistantSceneModel
@Bindable var conversation: ConversationModel
let focus: FocusState<Bool>.Binding?
let focusRequest: Int?
let focusRequestHandled: (Int) -> Void
@State private var files = false
@State private var photos = false
@State private var camera = false
@State private var photoItems: [PhotosPickerItem] = []
@State private var photoTask: Task<Void, Never>?
@State private var speechDraft = ""
@State private var contextDetails = false
@State private var isSlashPanelDismissed = false
@State private var slashPanelHeight: CGFloat = 0
private let slashRegistry = ChatSlashCommandRegistry.standard()
@FocusState private var localFocus: Bool
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.locale) private var locale
private var composer: ConversationComposerState { conversation.composer }
private var ownsTranscription: Bool { model.speech.isOwned(by: conversation.id) }
private var showsTranscriptionControls: Bool {
ownsTranscription && model.speech.phase != .idle
}
private var activeFocus: FocusState<Bool>.Binding { focus ?? $localFocus }
init(
pro: ProAccessController,
model: MobileAssistantSceneModel,
conversation: ConversationModel,
focus: FocusState<Bool>.Binding? = nil,
focusRequest: Int? = nil,
focusRequestHandled: @escaping (Int) -> Void = { _ in }
) {
self.pro = pro
self.model = model
self.conversation = conversation
self.focus = focus
self.focusRequest = focusRequest
self.focusRequestHandled = focusRequestHandled
}
var body: some View {
composerSurface
.sheet(isPresented: $contextDetails) { contextDetailsSheet }
.fileImporter(
isPresented: $files, allowedContentTypes: ContextAttachmentImporter.supportedContentTypes,
allowsMultipleSelection: true
) { result in
switch result {
case .success(let urls): conversation.importAttachmentFiles(urls)
case .failure(let error): composer.attachmentImportError = error.localizedDescription
}
}
.alert(
"Attachment Error",
isPresented: Binding(
get: { composer.attachmentImportError != nil },
set: { if !$0 { composer.attachmentImportError = nil } }
)
) {
Button("OK", role: .cancel) {}
} message: {
Text(composer.attachmentImportError ?? "")
}
.photosPicker(
isPresented: $photos, selection: $photoItems,
maxSelectionCount: max(1, 20 - composer.attachments.count - composer.pendingAttachments.count),
matching: .images
)
.onChange(of: photoItems) { _, items in
photoTask?.cancel()
photoTask = Task { await importPhotos(items) }
}
.fullScreenCover(isPresented: $camera) {
MobileAssistantCamera { data in
camera = false
if let data { conversation.importAttachmentImages([data], label: String(localized: "Photo")) }
}.ignoresSafeArea()
}
.alert(
model.speech.error?.title ?? "Transcription Failed",
isPresented: Binding(
get: { model.speech.errorOwnerID == conversation.id },
set: { if !$0 { model.speech.dismissError(ownerID: conversation.id) } })
) {
if model.speech.error?.offersSystemSettings == true {
Button("Settings") {
if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) }
}
}
Button("OK") { model.speech.dismissError(ownerID: conversation.id) }
} message: {
Text(model.speech.error?.message ?? "")
}
.onDisappear {
photoTask?.cancel()
model.speech.cancel(ownerID: conversation.id)
}
.task(id: focusRequest) {
guard let focusRequest else { return }
await Task.yield()
guard !Task.isCancelled else { return }
activeFocus.wrappedValue = true
focusRequestHandled(focusRequest)
}
.onAppear(perform: normalizeReasoningEffort)
.onChange(of: reasoningResolution) { _, _ in normalizeReasoningEffort() }
.background { keyboardShortcuts }
}
private var keyboardShortcuts: some View {
Group {
Button("Send", action: submitFromKeyboard)
.keyboardShortcut(MobileShortcut.send)
.disabled(!canSend)
Button("Stop", action: conversation.stop)
.keyboardShortcut(MobileShortcut.stop)
.disabled(!conversation.isAgentRunActive)
}
.frame(width: 0, height: 0)
.opacity(0)
.accessibilityHidden(true)
}
private var composerSurface: some View {
VStack(alignment: .leading, spacing: 8) {
if !composer.attachments.isEmpty || !composer.pendingAttachments.isEmpty {
attachmentRow
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
TextField(
"Ask anything…", text: Binding(get: { composer.draft }, set: { composer.draft = $0 }),
axis: .vertical
)
.lineLimit(1...6)
.focused(activeFocus)
.disabled(ownsTranscription)
.padding(.horizontal, 4)
.padding(.top, 4)
.submitLabel(.send)
.onSubmit(submitFromKeyboard)
.onKeyPress(.escape, phases: .down) { _ in
guard !slashCompletion.isEmpty, !isSlashPanelDismissed else { return .ignored }
isSlashPanelDismissed = true
return .handled
}
ZStack {
if showsTranscriptionControls {
recordingControls
.transition(.opacity)
} else {
standardControls
.transition(.opacity)
}
}
.frame(maxWidth: .infinity, minHeight: 48, maxHeight: 48)
.animation(reduceMotion ? nil : .smooth(duration: 0.22), value: showsTranscriptionControls)
}
.mobileAssistantInteractionSurface()
.overlay(alignment: .top) {
if !slashCompletion.isEmpty, !isSlashPanelDismissed {
MobileSlashCommandPanel(
completion: slashCompletion,
contextShare: contextShare,
hasOptions: { !slashOptions($0).isEmpty },
trailingText: slashTrailingText,
isCurrent: isCurrentSlashOption,
onSelect: acceptSlashRow
)
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.size.height
} action: { height in
slashPanelHeight = height
}
.offset(y: -(slashPanelHeight + 8))
.transition(.opacity.combined(with: .move(edge: .bottom)))
.zIndex(20)
}
}
.padding(.top, !slashCompletion.isEmpty && !isSlashPanelDismissed ? slashPanelHeight + 8 : 0)
.animation(.snappy(duration: 0.2), value: slashCompletion.rows.map(\.id))
.onChange(of: composer.draft) { _, _ in isSlashPanelDismissed = false }
}
private var standardControls: some View {
HStack(alignment: .center, spacing: 2) {
addMenu
permissionMenu
badges
Spacer(minLength: 4)
contextButton
microphoneButton
primaryButton
}
.frame(minHeight: 48)
}
@ViewBuilder
private var recordingControls: some View {
switch model.speech.phase {
case .recording:
speechControls {
SpeechWaveformView(
samples: model.speech.levelSamples,
reduceMotion: reduceMotion
)
.frame(maxWidth: .infinity, minHeight: 48)
.accessibilityLabel(Text("Recording"))
}
case .transcribing:
speechControls {
ProgressView()
.frame(maxWidth: .infinity)
.accessibilityLabel(Text("Transcribing"))
}
case .idle:
EmptyView()
}
}
private func speechControls<Status: View>(@ViewBuilder status: () -> Status) -> some View {
HStack(alignment: .center, spacing: 8) {
Button {
composer.draft = speechDraft
model.speech.cancel(ownerID: conversation.id)
activeFocus.wrappedValue = true
} label: {
Label("Cancel", systemImage: "xmark").labelStyle(.iconOnly)
.mobileComposerControlIcon()
}
.buttonStyle(.plain)
status()
Button {
model.speech.stop(ownerID: conversation.id)
} label: {
Label("Done", systemImage: "stop.fill").labelStyle(.iconOnly)
.mobileComposerControlIcon()
}
.buttonStyle(.plain)
.disabled(model.speech.phase != .recording)
}
.frame(minHeight: 48)
}
private var addMenu: some View {
Menu {
Button("Camera", systemImage: "camera") { openCamera() }
.disabled(!UIImagePickerController.isSourceTypeAvailable(.camera))
Button("Photos", systemImage: "photo") { photos = true }
Button("Files", systemImage: "doc") { files = true }
} label: {
Image(systemName: "plus")
.mobileComposerControlIcon()
}
.buttonStyle(.plain)
.accessibilityLabel("Add Attachment")
}
private var permissionMenu: some View {
Menu {
ForEach(AgentPermissionMode.allCases) { mode in
Button(role: mode == .fullAccess ? .destructive : nil) {
conversation.permissionMode = mode
model.configuration.select(mode)
} label: {
Label(
mode.displayName,
systemImage: mode == resolvedPermissionMode ? "checkmark" : mode.symbol
)
}
}
} label: {
Label("Permissions", systemImage: resolvedPermissionMode.symbol)
.labelStyle(.iconOnly)
.mobileComposerControlIcon(isDestructive: resolvedPermissionMode == .fullAccess)
}
.menuOrder(.fixed)
.buttonStyle(.plain)
.fixedSize()
.layoutPriority(1)
.accessibilityLabel("Permissions")
}
private var resolvedPermissionMode: AgentPermissionMode {
model.configuration.resolve(conversation: conversation.permissionMode)
}
private var contextButton: some View {
Button {
contextDetails = true
} label: {
ContextRing(share: contextShare ?? 0, diameter: 28, lineWidth: 4)
.frame(width: 48, height: 48).contentShape(.circle)
}
.buttonStyle(.plain)
.accessibilityLabel(Text(modelAndEffortAccessibilityLabel))
.accessibilityValue(Text(conversation.contextEstimate.total, format: .number))
}
private var microphoneButton: some View {
Button(action: startSpeech) {
Group {
if ownsTranscription && model.speech.isRequestingAuthorization {
ProgressView().controlSize(.small)
} else {
Label("Voice Input", systemImage: "mic").labelStyle(.iconOnly)
}
}
.mobileComposerControlIcon()
}
.buttonStyle(.plain)
.disabled(
!model.speech.canStart(for: conversation.id)
|| (ownsTranscription && model.speech.isRequestingAuthorization)
)
.accessibilityLabel(Text("Voice Input"))
}
@ViewBuilder private var primaryButton: some View {
if conversation.isPreparingAgentRun || (conversation.isStreaming && !canSend) {
Button(action: conversation.stop) {
Label("Stop", systemImage: "stop.circle.fill").labelStyle(.iconOnly)
.font(.system(size: 34, weight: .semibold)).foregroundStyle(.red)
.frame(width: 48, height: 48).contentShape(.circle)
}
} else {
Button(action: send) {
Label("Send", systemImage: "arrow.up.circle.fill").labelStyle(.iconOnly)
.font(.system(size: 34, weight: .semibold)).frame(width: 48, height: 48).contentShape(.circle)
}
.disabled(!canSend)
}
}
private var contextDetailsSheet: some View {
NavigationStack {
Form {
if conversation.contextEstimate.total > 0 {
Section("Context") {
ContextBreakdownView(
estimate: conversation.contextEstimate,
contextWindow: resolved?.model.contextLength,
cachedInputTokens: conversation.usage?.cachedInputTokens ?? 0,
showsTitle: false
)
}
}
Section("Model") {
assistantModelPicker
if supportsThinking, let reasoningResolution {
Picker("Effort", selection: $conversation.reasoningEffort) {
ForEach(reasoningResolution.supported, id: \.self) { effort in
Text(effort.displayName).tag(effort)
}
}
}
}
}
.navigationTitle("Model")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done", systemImage: "checkmark") { contextDetails = false }
.labelStyle(.iconOnly)
.buttonStyle(.plain)
.foregroundStyle(.primary)
}
}
}
.mobileSheetPresentation(compact: true)
}
private var canSend: Bool {
composer.pendingAttachments.isEmpty && !conversation.isCompacting
&& (!composer.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| !composer.attachments.isEmpty)
}
private var attachmentRow: some View {
ScrollView(.horizontal) {
HStack(spacing: 6) {
ForEach(composer.attachments) { chip in
Button {
conversation.detach(chip.id)
} label: {
HStack(spacing: 6) {
if let image = chip.image, let ui = UIImage(data: image.data) {
Image(uiImage: ui).resizable().scaledToFill()
.frame(width: 24, height: 24).clipShape(.rect(cornerRadius: 6))
} else {
Image(systemName: chip.kind.symbol).font(.caption)
}
Text(chip.label).font(.caption).lineLimit(1)
Image(systemName: "xmark.circle.fill")
.font(.caption.weight(.semibold)).foregroundStyle(Color.primary.opacity(0.55))
}
.padding(.horizontal, 10).padding(.vertical, 5)
.frame(minHeight: 44)
.background(Color.primary.opacity(0.08), in: .capsule)
.overlay { Capsule().stroke(Color.primary.opacity(0.08), lineWidth: 1) }
.contentShape(.capsule)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("Remove \(chip.label)"))
}
ForEach(composer.pendingAttachments) { pending in
Button {
conversation.cancelAttachmentImport(pending.id)
} label: {
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text(pending.label).font(.caption).lineLimit(1)
Image(systemName: "xmark.circle.fill")
.font(.caption.weight(.semibold)).foregroundStyle(Color.primary.opacity(0.55))
}
.padding(.horizontal, 10).padding(.vertical, 5)
.frame(minHeight: 44)
.background(Color.primary.opacity(0.08), in: .capsule)
.overlay { Capsule().stroke(Color.primary.opacity(0.08), lineWidth: 1) }
.contentShape(.capsule)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("Cancel importing \(pending.label)"))
}
}
}.scrollIndicators(.hidden).fixedSize(horizontal: false, vertical: true)
}
@ViewBuilder private var assistantModelPicker: some View {
if modelProviders.isEmpty {
Text("No models configured").foregroundStyle(.secondary)
} else {
Picker(
"Model",
selection: Binding(
get: { resolvedModelRef },
set: { if let value = $0 { model.selectModel(value) } })
) {
ForEach(modelProviders) { provider in
Section(provider.name) {
ForEach(provider.assistantModels) { item in
Text(item.label).tag(
AIModelRef(modelProviderID: provider.id, modelID: item.id) as AIModelRef?)
}
}
}
}
}
}
@ViewBuilder private var badges: some View {
if conversation.isPlanning {
MobileDismissibleComposerBadge(
symbol: "lightbulb.max",
help: Text("Plan mode — click to turn off"),
accessibilityLabel: Text("Plan mode on, turn off")
) {
conversation.isPlanning = false
}
}
if let skill = composer.armedSkillName {
MobileDismissibleComposerBadge(
symbol: "book.closed",
help: Text("\(skill) will be loaded for this message — click to remove"),
accessibilityLabel: Text("Skill \(skill) armed, remove")
) {
composer.armedSkillName = nil
}
}
}
private var modelProviders: [ModelProvider] {
model.configuration.enabledModelProviders.filter { !$0.assistantModels.isEmpty }
}
private var resolvedModelRef: AIModelRef? {
guard let resolved else { return nil }
return AIModelRef(modelProviderID: resolved.provider.id, modelID: resolved.model.id)
}
private var resolved: (provider: ModelProvider, model: AIModel)? {
model.configuration.resolve(conversation: conversation.model)
}
private var reasoningResolution: ModelCapabilityResolver.ReasoningResolution? {
guard let resolved else { return nil }
return ModelCapabilityResolver.reasoning(model: resolved.model, provider: resolved.provider)
}
private var effectiveReasoningEffort: ReasoningEffort {
reasoningResolution?.clamp(conversation.reasoningEffort) ?? .off
}
private var supportsThinking: Bool {
reasoningResolution?.supported.contains(where: { $0 != .off }) == true
}
private var modelAndEffortAccessibilityLabel: String {
guard let resolved else { return String(localized: "Model, no model configured") }
guard supportsThinking else { return String(localized: "Model \(resolved.model.label)") }
return String(
localized: "Model \(resolved.model.label), effort \(effectiveReasoningEffort.displayName)"
)
}
var availableCommands: [ChatSlashCommand] {
slashRegistry.commands(in: slashContext)
}
private func slashOptions(_ command: ChatSlashCommand) -> [ChatSlashCommand.Option] {
slashRegistry.options(for: command, in: slashContext)
}
private var slashCompletion: ChatSlashCommandCompletion {
slashRegistry.completion(for: composer.draft, in: slashContext)
}
private var contextShare: Double? {
guard conversation.contextEstimate.total > 0 else { return nil }
return conversation.contextEstimate.share(of: resolved?.model.contextLength)
}
private func slashTrailingText(_ row: ChatSlashCommandCompletion.Row) -> String? {
slashRegistry.trailingText(for: row, in: slashContext)
}
private func isCurrentSlashOption(_ row: ChatSlashCommandCompletion.Row) -> Bool {
slashRegistry.isCurrent(row, in: slashContext)
}
private func localized(_ resource: String.LocalizationValue) -> String {
let candidates = [locale.identifier, locale.language.languageCode?.identifier].compactMap { $0 }
let bundle =
candidates.lazy.compactMap { identifier in
Bundle.main.path(forResource: identifier, ofType: "lproj").flatMap(Bundle.init(path:))
}.first ?? .main
return String(localized: resource, bundle: bundle, locale: locale)
}
private func acceptSlashRow(_ row: ChatSlashCommandCompletion.Row) {
switch row {
case .command(let command):
let hasOptions = !slashOptions(command).isEmpty
composer.draft = ChatSlashCommandCompletion.accepted(
row, takesArgument: hasOptions, in: composer.draft
)
if !hasOptions { _ = executeSlash() }
case .option:
composer.draft = ChatSlashCommandCompletion.accepted(
row, takesArgument: false, in: composer.draft
)
_ = executeSlash()
}
activeFocus.wrappedValue = true
}
private func send() {
if executeSlash() { return }
model.send()
}
private func submitFromKeyboard() {
if !isSlashPanelDismissed, let first = slashCompletion.rows.first {
acceptSlashRow(first)
} else {
send()
}
}
@discardableResult private func executeSlash() -> Bool {
slashRegistry.perform(draft: composer.draft, in: slashContext)
}
private var slashContext: ChatSlashCommandContext {
ChatSlashCommandContext(
platform: .mobile,
conversation: conversation,
configuration: model.configuration,
skills: model.skills,
compact: { instructions in
Task { await model.compact(conversation, instructions: instructions) }
},
send: model.send,
authorizeRemoteMCP: { pro.require(.remoteMCP) },
localized: localized
)
}
private func normalizeReasoningEffort() {
let normalized = effectiveReasoningEffort
if conversation.reasoningEffort != normalized {
conversation.reasoningEffort = normalized
}
}
private func importPhotos(_ items: [PhotosPickerItem]) async {
for item in items {
guard !Task.isCancelled, let pending = conversation.beginAttachmentImport(label: String(localized: "Photo"))
else { break }
do {
guard let data = try await item.loadTransferable(type: Data.self) else {
throw CocoaError(.fileReadCorruptFile)
}
try Task.checkCancellation()
let chip = try await conversation.attachmentLoader.image(
data, String(localized: "Photo"))
try Task.checkCancellation()
conversation.finishAttachmentImport(pending.id, with: chip)
} catch is CancellationError { conversation.cancelAttachmentImport(pending.id) } catch {
conversation.failAttachmentImport(pending.id, message: error.localizedDescription)
}
}
}
private func startSpeech() {
speechDraft = composer.draft
activeFocus.wrappedValue = false
Task {
await model.speech.start(ownerID: conversation.id) { text, _ in
guard model.conversation.id == conversation.id else { return }
_ = conversation.applyTranscription(text, sendAfterTranscription: false, send: {})
}
}
}
private func openCamera() {
Task {
let granted = await AVCaptureDevice.requestAccess(for: .video)
if granted {
camera = true
} else {
composer.attachmentImportError = String(localized: "Allow camera access in Settings to take a photo.")
}
}
}
}
private struct MobileComposerControlIconModifier: ViewModifier {
let isDestructive: Bool
let opacity: Double
private var tint: Color {
isDestructive ? .red : Color.primary.opacity(opacity)
}
func body(content: Content) -> some View {
content
.font(.title3.weight(.medium))
.symbolRenderingMode(.monochrome)
.foregroundStyle(tint)
.frame(width: 48, height: 48, alignment: .center)
.contentShape(.circle)
}
}
extension View {
fileprivate func mobileComposerControlIcon(isDestructive: Bool = false, opacity: Double = 0.75) -> some View {
modifier(MobileComposerControlIconModifier(isDestructive: isDestructive, opacity: opacity))
}
}