// AIKeyboardCoordinator.swift // OSGKeyboard ยท Keyboard Extension // // Owns the temporary AI-mode UI state. Audio, ASR, and LLM work remain in the // shared Flow transport and host app; this coordinator never performs network // work and never inserts an answer before explicit user confirmation. import Foundation import OSGKeyboardShared @MainActor final class AIKeyboardCoordinator { private let state: KeyboardState private let flow: KeyboardFlowCoordinator private let insertAnswer: (AIAnswer) -> Bool private let performReturn: () -> Void init( state: KeyboardState, flow: KeyboardFlowCoordinator, insertAnswer: @escaping (AIAnswer) -> Bool, performReturn: @escaping () -> Void ) { self.state = state self.flow = flow self.insertAnswer = insertAnswer self.performReturn = performReturn } func beginNewPresentation() { endConversationIfNeeded() state.aiSession.enter() } func enterIfNeeded() { guard !state.aiSession.isActive else { return } state.aiSession.enter() } func leave() { if state.aiSession.isBusy { flow.cancelAIRecording() } endConversationIfNeeded() state.aiSession.leave() } func toggleMicrophone() { switch state.aiSession.phase { case .listening: guard let utteranceID = state.aiSession.activeUtteranceID else { return } state.aiSession.beginRecognizing(utteranceID: utteranceID) flow.stopAIRecording() case .idle, .ready, .awaitingSend, .inserted, .sent, .failed: enterIfNeeded() guard let conversationID = state.aiSession.conversationID else { return } let disposition = flow.beginAIRecording(conversationID: conversationID) if case .rejected(let rejection) = disposition { state.aiSession.fail(message(for: rejection), utteranceID: nil) } case .inactive: enterIfNeeded() toggleMicrophone() case .preparing, .recognizing, .generating: break } } /// Tap a clipboard skill chip: same fail-closed material path as hint cards. func submitClipboardSkill(_ skill: AIClipboardSkill) { guard canAcceptIdleSubmit else { return } enterIfNeeded() state.pendingClipboardSkillID = skill.kind == .export ? skill.id : nil let instruction = AIClipboardSkillCatalog.instruction( for: skill, locale: AIHintLocaleResolver.packLocale(), translationTargetLocaleId: state.translationTargetLocaleId ) let material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text AIAgentShortcutRun.trace("keyboard.submit skill=\(skill.id) kind=\(skill.kind)") if let material { AIAgentShortcutRun.traceBody("keyboard.clipboard", material) } else { AIAgentShortcutRun.trace("keyboard.clipboard missing") } let resolution = AIClipboardPrompt.resolve( instruction: instruction, material: material ) if case .materialUnavailable = resolution { AIAgentShortcutRun.trace("keyboard.submit rejected clipboardUnavailable skill=\(skill.id)") } submitResolvedPrompt(resolution) } /// Tap an idle hint card: resolve its material, skip the mic, ask the host. func submitHintCard(_ card: AIHintCard) { guard canAcceptIdleSubmit else { return } enterIfNeeded() let resolution = AIHintPool.resolvePrompt( for: card, clipboardText: ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text ) submitResolvedPrompt(resolution) } private var canAcceptIdleSubmit: Bool { switch state.aiSession.phase { case .inactive, .idle, .failed: return true case .preparing, .listening, .recognizing, .generating, .ready, .awaitingSend, .inserted, .sent: return false } } private func submitResolvedPrompt(_ resolution: AIClipboardPrompt.Resolution) { guard case .ready(let prompt) = resolution else { // The clipboard window closed between rendering and this tap. state.pendingClipboardSkillID = nil state.aiSession.fail( ExtL10n.string("keyboard.ai.error.clipboardUnavailable"), utteranceID: nil ) return } guard let conversationID = state.aiSession.conversationID else { state.pendingClipboardSkillID = nil return } let disposition = flow.submitAIQuestion( text: prompt, conversationID: conversationID ) if case .rejected(let rejection) = disposition { state.pendingClipboardSkillID = nil state.aiSession.fail(message(for: rejection), utteranceID: nil) } } func cancel() { guard state.aiSession.isBusy else { return } state.pendingClipboardSkillID = nil flow.cancelAIRecording() state.aiSession.cancelCurrentWork() } func sendLatestAnswer() { if state.aiSession.canInsert, let answer = state.aiSession.answer { guard insertAnswer(answer) else { return } state.aiSession.markAnswerInserted( offersSend: state.returnKeyRole == .send ) } else if state.aiSession.canSend { state.aiSession.markAnswerSent() // Let the host consume the inserted answer before issuing Return. Task { @MainActor [weak self] in await Task.yield() self?.performReturn() } } } func utterancePrepared(_ utteranceID: UUID) { state.aiSession.beginPreparing(utteranceID: utteranceID) } func recordingStarted(_ utteranceID: UUID) { state.aiSession.beginListening(utteranceID: utteranceID) } func recognitionStarted(_ utteranceID: UUID) { state.aiSession.beginRecognizing(utteranceID: utteranceID) } func generatingStarted(_ utteranceID: UUID) { state.aiSession.beginGenerating(question: "", utteranceID: utteranceID) } func receiveTranscript( _ transcript: String, utteranceID: UUID, status: FlowResult.Status ) { if AIClipboardPrompt.isInternalPrompt(transcript) { if status == .rawReady { state.aiSession.beginGenerating(question: "", utteranceID: utteranceID) } return } state.aiSession.updateTranscript(transcript, utteranceID: utteranceID) if status == .rawReady { state.aiSession.beginGenerating( question: transcript, utteranceID: utteranceID ) } } func receivePartialAnswer(_ draft: String, utteranceID: UUID) { if isPendingExportSkill { return } state.aiSession.receivePartialAnswer(draft, utteranceID: utteranceID) } func receive(result: FlowResult) { guard result.resolvedUtteranceMode == .aiQuestion else { return } if isPendingExportSkill { guard result.aiConversationID == state.aiSession.conversationID else { return } finishExportSkill(answer: result.text ?? "") return } guard result.aiConversationID == state.aiSession.conversationID, let answer = result.text, !answer.isEmpty else { state.aiSession.fail( ExtL10n.string("keyboard.ai.error.requestFailed"), utteranceID: result.utteranceId ) return } state.aiSession.receiveAnswer(answer, utteranceID: result.utteranceId) } func fail(_ message: String, utteranceID: UUID?) { state.pendingClipboardSkillID = nil state.aiSession.fail(message, utteranceID: utteranceID) } private func endConversationIfNeeded() { state.pendingClipboardSkillID = nil guard let conversationID = state.aiSession.conversationID else { return } flow.endAIConversation(conversationID) } private var isPendingExportSkill: Bool { guard let id = state.pendingClipboardSkillID else { return false } return AIClipboardSkillCatalog.skill(id: id)?.kind == .export } /// Parse extract-todos. Empty โ†’ in-keyboard tip, stay in the host app. /// Titles โ†’ hand off to the host to run the companion Shortcut. private func finishExportSkill(answer: String) { let source = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text let items = AITodoExtraction.items(from: answer, sourceClipboard: source) AIAgentShortcutRun.traceBody("keyboard.llmRaw", answer) AIAgentShortcutRun.trace( "keyboard.parse items=\(items.count) clipboardChars=\(source?.count ?? 0)" ) #if DEBUG if !items.isEmpty { AIAgentShortcutRun.traceBody("keyboard.parsedTitles", items.joined(separator: "\n")) } #endif let skillID = state.pendingClipboardSkillID state.pendingClipboardSkillID = nil if state.aiSession.isBusy { state.aiSession.cancelCurrentWork() } if state.aiSession.isActive { state.aiSession.resetConversationPreservingAnswer() } guard !items.isEmpty else { AIAgentShortcutRun.trace("keyboard.parse empty โ€” skip Shortcuts") state.skillTipText = ExtL10n.string("keyboard.ai.skill.noTodos") return } guard let skillID, AIClipboardSkillCatalog.skill(id: skillID)?.shortcutName != nil else { AIAgentShortcutRun.trace("keyboard.parse missingShortcut skill=\(skillID ?? "nil")") state.skillTipText = ExtL10n.string("keyboard.ai.skill.shortcutMissing") return } AIAgentShortcutRun.trace("keyboard.handoffToHost skill=\(skillID) items=\(items.count)") state.skillTipText = ExtL10n.string("keyboard.ai.skill.runningShortcut") state.runClipboardExportSkill(skillID, items) } private func message(for rejection: FlowUtteranceStartRejection) -> String { switch rejection { case .onboardingIncomplete: return ExtL10n.string("keyboard.hint.finishSetupInApp") case .missingAPIKey: return ExtL10n.string("keyboard.ai.error.missingAPIKey") case .noFullAccess: return ExtL10n.string("keyboard.error.fullAccessRequired") case .appGroupUnavailable: return ExtL10n.string("keyboard.error.appGroupCommunication") case .hostUnavailable: return ExtL10n.string("keyboard.flow.hostDisconnected") case .pipelineBusy: return ExtL10n.string("keyboard.ai.error.pipelineBusy") } } }