Files
OSGKeyboard/OSGKeyboardShared/Services/AIHintPool.swift
T
Rocky 631617ee53 feat(keyboard): add clipboard AI skills, hint keywords, and voice session fixes
Idle chips show entities with category icons; a fresh copy surfaces Reply/Summarize/Translate; abort/cancel/empty-tap no longer leave the mic stuck.
2026-08-13 15:47:32 +08:00

82 lines
2.9 KiB
Swift

// AIHintPool.swift
// OSGKeyboard · Shared
//
// Builds the idle carousel pool from local + remote cards. Clipboard
// sentence cards are excluded: the 30s copy window shows skill chips.
import Foundation
public enum AIHintPool: Sendable {
public static func activeCards(
pack: AIHintPack
) -> [AIHintCard] {
let regularCards = pack.cards.filter { !$0.requiresClipboard30s }
.filter { !isHistoricalToday($0) }
// Clipboard-conditioned sentences no longer rotate in the carousel;
// the 30s window shows skill chips instead. Keep evergreen content
// loaded so leaving the window does not flash a leftover clipboard card.
var merged = regularCards
let localRegular = AIHintLocalCatalog.cards(locale: pack.locale)
.filter { !$0.requiresClipboard30s }
for card in localRegular where !merged.contains(where: { $0.id == card.id }) {
merged.append(card)
}
return merged.sorted { $0.priority > $1.priority }
}
/// Copy-then-30s window where clipboard skill chips replace the carousel.
public static func isClipboardSkillWindowActive(
clipboardHistoryEnabled: Bool,
newestClipboard: ClipboardHistoryEntry?,
now: Date = Date()
) -> Bool {
clipboardHistoryEnabled
&& newestClipboard.map { ClipboardHistoryPolicy.isEligibleForAIHint($0, now: now) } == true
}
/// Prompt for a tapped card. Clipboard cards fail closed so an expired
/// window can never send an instruction without its material.
public static func resolvePrompt(
for card: AIHintCard,
clipboardText: String?
) -> AIClipboardPrompt.Resolution {
guard card.requiresClipboard30s else {
return .ready(AIClipboardPrompt.strippingPlaceholder(card.prompt))
}
return AIClipboardPrompt.resolve(
instruction: card.prompt,
material: clipboardText
)
}
private static func isHistoricalToday(_ card: AIHintCard) -> Bool {
let haystack = (card.displayText + " " + card.prompt)
return haystack.contains("历史上的今天") || haystack.localizedCaseInsensitiveContains("on this day")
}
}
/// Shuffle-bag rotator for the idle carousel.
public struct AIHintCarouselBag: Sendable {
private var bag: [AIHintCard] = []
private var sourceFingerprint: Int = 0
public init() {}
public mutating func next(from cards: [AIHintCard]) -> AIHintCard? {
guard !cards.isEmpty else { return nil }
let fingerprint = cards.map(\.id).joined(separator: "|").hashValue
if bag.isEmpty || fingerprint != sourceFingerprint {
sourceFingerprint = fingerprint
bag = cards.shuffled()
}
if bag.isEmpty { return nil }
return bag.removeFirst()
}
public mutating func reset() {
bag = []
sourceFingerprint = 0
}
}