631617ee53
Idle chips show entities with category icons; a fresh copy surfaces Reply/Summarize/Translate; abort/cancel/empty-tap no longer leave the mic stuck.
243 lines
7.1 KiB
Swift
243 lines
7.1 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
|
|
}
|
|
|
|
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
|
|
}
|