Files
OSGKeyboard/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift
T
Rocky 9f308fadd2 feat(keyboard): ship AI hint carousel, home library cards, and clipboard polish
Rotate AI idle suggestions with optional remote packs, move history/dictionary onto self-sizing Home preview cards, harden clipboard capture/prompting, and simplify keyboard chrome by dropping most liquid-glass shadows.
2026-08-13 01:00:51 +08:00

195 lines
6.5 KiB
Swift

// 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 an idle hint card: resolve its material, skip the mic, ask the host.
func submitHintCard(_ card: AIHintCard) {
switch state.aiSession.phase {
case .inactive, .idle, .failed:
break
case .preparing, .listening, .recognizing, .generating,
.ready, .awaitingSend, .inserted, .sent:
return
}
enterIfNeeded()
let resolution = AIHintPool.resolvePrompt(
for: card,
clipboardText: ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
)
guard case .ready(let prompt) = resolution else {
// The clipboard window closed between rendering and this tap.
state.aiSession.fail(
ExtL10n.string("keyboard.ai.error.clipboardUnavailable"),
utteranceID: nil
)
return
}
guard let conversationID = state.aiSession.conversationID else { return }
let disposition = flow.submitAIQuestion(
text: prompt,
conversationID: conversationID
)
if case .rejected(let rejection) = disposition {
state.aiSession.fail(message(for: rejection), utteranceID: nil)
}
}
func cancel() {
guard state.aiSession.isBusy else { return }
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 receiveTranscript(
_ transcript: String,
utteranceID: UUID,
status: FlowResult.Status
) {
state.aiSession.updateTranscript(transcript, utteranceID: utteranceID)
if status == .rawReady {
state.aiSession.beginGenerating(
question: transcript,
utteranceID: utteranceID
)
}
}
func receivePartialAnswer(_ draft: String, utteranceID: UUID) {
state.aiSession.receivePartialAnswer(draft, utteranceID: utteranceID)
}
func receive(result: FlowResult) {
guard result.resolvedUtteranceMode == .aiQuestion else {
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.aiSession.fail(message, utteranceID: utteranceID)
}
private func endConversationIfNeeded() {
guard let conversationID = state.aiSession.conversationID else { return }
flow.endAIConversation(conversationID)
}
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")
}
}
}