fb97ec2937
Merge dictation and AI controls into one assistant surface, preserve safe insertion and clipboard actions, and align settings and tests with the new flow.
255 lines
7.5 KiB
Swift
255 lines
7.5 KiB
Swift
// AISessionState.swift
|
|
// OSGKeyboard · Shared
|
|
//
|
|
// Keyboard-local state for one temporary AI conversation. Conversation
|
|
// messages live in the host process; the extension keeps only the latest
|
|
// answer needed for review and explicit insertion.
|
|
|
|
import Foundation
|
|
|
|
public struct AIAnswer: Equatable, Identifiable, Sendable {
|
|
public enum DeliveryState: Equatable, Sendable {
|
|
case ready
|
|
case awaitingSend
|
|
case inserted
|
|
case sent
|
|
}
|
|
|
|
public let id: UUID
|
|
public let text: String
|
|
public let createdAt: Date
|
|
public private(set) var deliveryState: DeliveryState
|
|
|
|
public var isInserted: Bool {
|
|
deliveryState != .ready
|
|
}
|
|
|
|
public var isSent: Bool {
|
|
deliveryState == .sent
|
|
}
|
|
|
|
public init(
|
|
id: UUID = UUID(),
|
|
text: String,
|
|
createdAt: Date = Date(),
|
|
isSent: Bool = false
|
|
) {
|
|
self.id = id
|
|
self.text = text
|
|
self.createdAt = createdAt
|
|
self.deliveryState = isSent ? .sent : .ready
|
|
}
|
|
|
|
public mutating func markInserted(offersSend: Bool) {
|
|
guard deliveryState == .ready else { return }
|
|
deliveryState = offersSend ? .awaitingSend : .inserted
|
|
}
|
|
|
|
public mutating func markSent() {
|
|
guard deliveryState == .awaitingSend else { return }
|
|
deliveryState = .sent
|
|
}
|
|
}
|
|
|
|
public struct AISessionState: Equatable, Sendable {
|
|
public enum Phase: Equatable, Sendable {
|
|
case inactive
|
|
case idle
|
|
case preparing
|
|
case listening
|
|
case recognizing
|
|
case generating
|
|
case ready
|
|
case awaitingSend
|
|
case inserted
|
|
case sent
|
|
case failed
|
|
}
|
|
|
|
public private(set) var phase: Phase
|
|
public private(set) var conversationID: UUID?
|
|
public private(set) var activeUtteranceID: UUID?
|
|
public private(set) var answer: AIAnswer?
|
|
/// Live LLM draft while `phase == .generating`. Cleared on final/cancel.
|
|
public private(set) var draftAnswerText: String?
|
|
public private(set) var transcript: String
|
|
public private(set) var errorMessage: String?
|
|
|
|
public static let inactive = AISessionState()
|
|
|
|
public init(
|
|
phase: Phase = .inactive,
|
|
conversationID: UUID? = nil,
|
|
activeUtteranceID: UUID? = nil,
|
|
answer: AIAnswer? = nil,
|
|
draftAnswerText: String? = nil,
|
|
transcript: String = "",
|
|
errorMessage: String? = nil
|
|
) {
|
|
self.phase = phase
|
|
self.conversationID = conversationID
|
|
self.activeUtteranceID = activeUtteranceID
|
|
self.answer = answer
|
|
self.draftAnswerText = draftAnswerText
|
|
self.transcript = transcript
|
|
self.errorMessage = errorMessage
|
|
}
|
|
|
|
public var isActive: Bool { phase != .inactive }
|
|
|
|
public var isBusy: Bool {
|
|
switch phase {
|
|
case .preparing, .listening, .recognizing, .generating:
|
|
return true
|
|
case .inactive, .idle, .ready, .awaitingSend, .inserted, .sent, .failed:
|
|
return false
|
|
}
|
|
}
|
|
|
|
public var canInsert: Bool {
|
|
phase == .ready && answer?.deliveryState == .ready
|
|
}
|
|
|
|
public var canSend: Bool {
|
|
phase == .awaitingSend && answer?.deliveryState == .awaitingSend
|
|
}
|
|
|
|
public var canPerformAnswerAction: Bool {
|
|
canInsert || canSend
|
|
}
|
|
|
|
public mutating func enter(conversationID: UUID = UUID()) {
|
|
self = AISessionState(phase: .idle, conversationID: conversationID)
|
|
}
|
|
|
|
public mutating func leave() {
|
|
self = .inactive
|
|
}
|
|
|
|
public mutating func beginPreparing(utteranceID: UUID) {
|
|
guard isActive, !isBusy else { return }
|
|
phase = .preparing
|
|
activeUtteranceID = utteranceID
|
|
transcript = ""
|
|
errorMessage = nil
|
|
}
|
|
|
|
public mutating func beginListening(utteranceID: UUID) {
|
|
guard isActive, activeUtteranceID == utteranceID else { return }
|
|
phase = .listening
|
|
errorMessage = nil
|
|
}
|
|
|
|
public mutating func updateTranscript(_ value: String, utteranceID: UUID) {
|
|
guard isActive, activeUtteranceID == utteranceID else { return }
|
|
transcript = value
|
|
}
|
|
|
|
public mutating func beginRecognizing(utteranceID: UUID) {
|
|
guard isActive, activeUtteranceID == utteranceID else { return }
|
|
phase = .recognizing
|
|
}
|
|
|
|
public mutating func beginGenerating(question: String, utteranceID: UUID) {
|
|
guard isActive, activeUtteranceID == utteranceID else { return }
|
|
transcript = question
|
|
draftAnswerText = nil
|
|
phase = .generating
|
|
}
|
|
|
|
/// Incremental AI answer draft. Keeps `phase == .generating` and does not
|
|
/// replace the previous committed `answer` until `receiveAnswer`.
|
|
public mutating func receivePartialAnswer(_ text: String, utteranceID: UUID) {
|
|
guard isActive, activeUtteranceID == utteranceID else { return }
|
|
if phase == .recognizing || phase == .preparing {
|
|
phase = .generating
|
|
}
|
|
guard phase == .generating else { return }
|
|
draftAnswerText = text
|
|
errorMessage = nil
|
|
}
|
|
|
|
public mutating func receiveAnswer(_ text: String, utteranceID: UUID) {
|
|
guard isActive, activeUtteranceID == utteranceID else { return }
|
|
answer = AIAnswer(text: text)
|
|
draftAnswerText = nil
|
|
phase = .ready
|
|
activeUtteranceID = nil
|
|
errorMessage = nil
|
|
}
|
|
|
|
public mutating func markAnswerInserted(offersSend: Bool) {
|
|
guard canInsert else { return }
|
|
answer?.markInserted(offersSend: offersSend)
|
|
phase = offersSend ? .awaitingSend : .inserted
|
|
}
|
|
|
|
public mutating func markAnswerSent() {
|
|
guard canSend else { return }
|
|
answer?.markSent()
|
|
phase = .sent
|
|
}
|
|
|
|
/// Drops a result retained because the insertion target changed.
|
|
/// The conversation remains active so the user can immediately ask again.
|
|
public mutating func discardReadyAnswer() {
|
|
guard phase == .ready else { return }
|
|
answer = nil
|
|
activeUtteranceID = nil
|
|
draftAnswerText = nil
|
|
transcript = ""
|
|
errorMessage = nil
|
|
phase = .idle
|
|
}
|
|
|
|
public mutating func cancelCurrentWork() {
|
|
guard isBusy else { return }
|
|
activeUtteranceID = nil
|
|
transcript = ""
|
|
draftAnswerText = nil
|
|
errorMessage = nil
|
|
phase = restingPhase
|
|
}
|
|
|
|
public mutating func fail(_ message: String, utteranceID: UUID?) {
|
|
guard isActive else { return }
|
|
if let utteranceID, activeUtteranceID != utteranceID { return }
|
|
activeUtteranceID = nil
|
|
draftAnswerText = nil
|
|
errorMessage = message
|
|
phase = .failed
|
|
}
|
|
|
|
public mutating func resetConversationPreservingAnswer(
|
|
conversationID: UUID = UUID()
|
|
) {
|
|
guard isActive else { return }
|
|
self.conversationID = conversationID
|
|
activeUtteranceID = nil
|
|
transcript = ""
|
|
draftAnswerText = nil
|
|
errorMessage = nil
|
|
phase = restingPhase
|
|
}
|
|
|
|
private var restingPhase: Phase {
|
|
guard let answer else { return .idle }
|
|
switch answer.deliveryState {
|
|
case .ready:
|
|
return .ready
|
|
case .awaitingSend:
|
|
return .awaitingSend
|
|
case .inserted:
|
|
return .inserted
|
|
case .sent:
|
|
return .sent
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum AIQuestionLimits {
|
|
public static let retainedConversationRounds = 6
|
|
/// Safety ceiling only — user-facing length is guided by prompt, not this cap.
|
|
public static let maximumAnswerCharacterCount = 4_500
|
|
}
|