Expand onboarding and adaptive keyboard intelligence
Add resilient usage analytics, OOBE gateway flows, clipboard semantic ranking, purchase recovery, style learning, and managed current-information search.
This commit is contained in:
@@ -1,22 +1,19 @@
|
||||
// AIAgentSkillLayout.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Enabled clipboard-skill slots (max 8, ordered) plus which export skills
|
||||
// the user confirmed a companion Shortcut for. Missing storage hydrates
|
||||
// to the three default transform skills; an explicit empty list is kept
|
||||
// Enabled clipboard skills (ordered) plus which export skills the user
|
||||
// confirmed a companion Shortcut for. Missing storage hydrates to every
|
||||
// built-in default skill; an explicit empty list is kept
|
||||
// so turning every skill off is distinct from a fresh install.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
|
||||
public static let maximumEnabled = 8
|
||||
public static let defaultEnabledIDs = [
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.translateID
|
||||
]
|
||||
public static let defaultEnabledIDs = AIClipboardSkillCatalog.catalog
|
||||
.filter(\.isDefault)
|
||||
.map(\.id)
|
||||
|
||||
/// Keyboard chip order. Unknown / unconfirmed IDs are dropped on sanitize.
|
||||
/// Keyboard chip order. Unknown and duplicate IDs are dropped on sanitize.
|
||||
public var enabledIDs: [String]
|
||||
/// Export skills whose companion Shortcut the user marked as added.
|
||||
public var confirmedShortcutIDs: [String]
|
||||
@@ -31,10 +28,6 @@ public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
|
||||
self.confirmedShortcutIDs = confirmedShortcutIDs
|
||||
}
|
||||
|
||||
public var isFull: Bool {
|
||||
enabledIDs.count >= Self.maximumEnabled
|
||||
}
|
||||
|
||||
public func isEnabled(_ id: String) -> Bool {
|
||||
enabledIDs.contains(id)
|
||||
}
|
||||
@@ -43,20 +36,16 @@ public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
|
||||
confirmedShortcutIDs.contains(id)
|
||||
}
|
||||
|
||||
/// Drops unknown IDs, unconfirmed export skills, and duplicates; caps at 8.
|
||||
/// Drops unknown IDs and duplicates. Shortcut setup is tracked separately
|
||||
/// so bundled export skills can remain visible as installed defaults.
|
||||
public func sanitized(
|
||||
catalog: [AIClipboardSkill] = AIClipboardSkillCatalog.catalog
|
||||
) -> AIAgentSkillLayout {
|
||||
let known = Dictionary(uniqueKeysWithValues: catalog.map { ($0.id, $0) })
|
||||
var seenEnabled = Set<String>()
|
||||
let enabled = enabledIDs.filter { id in
|
||||
guard let skill = known[id], seenEnabled.insert(id).inserted else { return false }
|
||||
if skill.requiresShortcut {
|
||||
return confirmedShortcutIDs.contains(id)
|
||||
}
|
||||
return true
|
||||
known[id] != nil && seenEnabled.insert(id).inserted
|
||||
}
|
||||
.prefix(Self.maximumEnabled)
|
||||
|
||||
var seenConfirmed = Set<String>()
|
||||
let confirmed = confirmedShortcutIDs.filter { id in
|
||||
@@ -65,7 +54,7 @@ public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
|
||||
}
|
||||
|
||||
return AIAgentSkillLayout(
|
||||
enabledIDs: Array(enabled),
|
||||
enabledIDs: enabled,
|
||||
confirmedShortcutIDs: confirmed
|
||||
)
|
||||
}
|
||||
@@ -75,6 +64,5 @@ public enum AIAgentSkillEnableResult: Equatable, Sendable {
|
||||
case enabled
|
||||
case alreadyEnabled
|
||||
case needsShortcut
|
||||
case full
|
||||
case unknown
|
||||
}
|
||||
|
||||
@@ -167,13 +167,13 @@ public struct AIHintManifest: Codable, Equatable, Sendable {
|
||||
}
|
||||
|
||||
public enum AIHintFeedEndpoints {
|
||||
public static let baseURL = URL(string: "https://key.osglab.com/hints")!
|
||||
public static let manifestURL = baseURL.appendingPathComponent("manifest.json")
|
||||
public static let baseURL = URL(string: "https://account.osglab.com/v1/content/hints")!
|
||||
public static let manifestURL = baseURL.appendingPathComponent("manifest")
|
||||
/// Packs the app fetches and the keyboard can resolve.
|
||||
public static let supportedLocales = ["zh", "en"]
|
||||
|
||||
public static func packURL(locale: String) -> URL {
|
||||
baseURL.appendingPathComponent("hints-\(locale).json")
|
||||
baseURL.appendingPathComponent(locale)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +194,9 @@ public enum AIHintAppGroupKeys {
|
||||
public static let readyPackPrefix = "hints.ready."
|
||||
public static let lastSuccessPrefix = "hints.meta.lastSuccessAt."
|
||||
public static let lastAttemptAt = "hints.meta.lastAttemptAt"
|
||||
public static let manifest = "hints.meta.manifest.v1"
|
||||
public static let manifestETag = "hints.meta.manifestETag.v1"
|
||||
public static let packETagPrefix = "hints.meta.packETag."
|
||||
|
||||
public static func readyPackKey(locale: String) -> String {
|
||||
readyPackPrefix + locale
|
||||
@@ -203,4 +206,8 @@ public enum AIHintAppGroupKeys {
|
||||
public static func lastSuccessKey(locale: String) -> String {
|
||||
lastSuccessPrefix + locale
|
||||
}
|
||||
|
||||
public static func packETagKey(locale: String) -> String {
|
||||
packETagPrefix + locale
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
public static let engineMode = "config.engineMode"
|
||||
/// Credential ownership is independent from local/cloud ASR selection.
|
||||
public static let credentialSource = "config.credentialSource"
|
||||
/// Device-local, non-secret gate for account-funded managed requests.
|
||||
/// This key is deliberately excluded from iCloud settings payloads.
|
||||
public static let managedGatewayAccountSessionAvailable =
|
||||
"account.managedGateway.sessionAvailable.v1"
|
||||
public static let hasCompletedOnboarding = "config.hasCompletedOnboarding"
|
||||
public static let onboardingPage = "config.onboardingPage"
|
||||
public static let hasAcknowledgedCloudSharing = "config.hasAcknowledgedCloudSharing"
|
||||
@@ -73,8 +77,13 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
public static let localASRCustomLanguageModelEnabled = "config.localASR.customLanguageModelEnabled"
|
||||
/// Enabled AI Agent skill IDs (order) + confirmed companion Shortcuts.
|
||||
public static let agentSkillLayout = "config.aiAgentSkills.layout.v1"
|
||||
/// One-shot: append semantic built-ins introduced with the unlimited layout.
|
||||
public static let agentSkillDefaultsMigrationVersion =
|
||||
"config.aiAgentSkills.defaultsMigrationVersion"
|
||||
/// User-created clipboard skills (no cloud sync; App Group only).
|
||||
public static let agentUserSkillCatalog = "config.aiAgentSkills.userCatalog.v1"
|
||||
/// Last-known-good official catalog plus revision, freshness, and ETag metadata.
|
||||
public static let officialSkillCatalog = "content.officialSkills.snapshot.v1"
|
||||
}
|
||||
|
||||
// MARK: - Stored fields
|
||||
@@ -195,13 +204,15 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
|
||||
public func makeClient(
|
||||
taskKind: ManagedGatewayTaskKind? = nil,
|
||||
requestPurpose: ManagedGatewayRequestPurpose? = nil
|
||||
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||
oobeFeature: ManagedGatewayOOBEFeature? = nil
|
||||
) -> LLMClient {
|
||||
if credentialSource == .managed {
|
||||
if credentialSource == .managed || requestPurpose == .oobe {
|
||||
return ManagedLLMClient(
|
||||
capability: .polish,
|
||||
taskKind: taskKind,
|
||||
requestPurpose: requestPurpose,
|
||||
oobeFeature: oobeFeature,
|
||||
grants: GatewayGrantCoordinator()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ public struct FlowCommand: Codable, Equatable, Sendable {
|
||||
case submitAIQuestion
|
||||
}
|
||||
|
||||
/// Wire version that includes managed-gateway request purpose.
|
||||
public static let currentProtocolVersion = 7
|
||||
/// Wire version that includes a strongly typed OOBE feature.
|
||||
public static let currentProtocolVersion = 8
|
||||
|
||||
public let protocolVersion: Int
|
||||
public let sessionId: UUID
|
||||
@@ -45,6 +45,8 @@ public struct FlowCommand: Codable, Equatable, Sendable {
|
||||
public let aiTaskKind: ManagedGatewayTaskKind?
|
||||
/// Optional server-audited purpose for managed gateway billing policy.
|
||||
public let managedRequestPurpose: ManagedGatewayRequestPurpose?
|
||||
/// Required feature discriminator when `managedRequestPurpose == .oobe`.
|
||||
public let managedOOBEFeature: ManagedGatewayOOBEFeature?
|
||||
/// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
|
||||
public let aiThinkingEnabled: Bool?
|
||||
/// Absolute wall-clock deadlines survive extension reconstruction.
|
||||
@@ -68,6 +70,7 @@ public struct FlowCommand: Codable, Equatable, Sendable {
|
||||
aiQuestionText: String? = nil,
|
||||
aiTaskKind: ManagedGatewayTaskKind? = nil,
|
||||
managedRequestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||
managedOOBEFeature: ManagedGatewayOOBEFeature? = nil,
|
||||
aiThinkingEnabled: Bool? = nil,
|
||||
startDeadlineAt: TimeInterval? = nil,
|
||||
processingDeadlineAt: TimeInterval? = nil
|
||||
@@ -88,6 +91,7 @@ public struct FlowCommand: Codable, Equatable, Sendable {
|
||||
self.aiQuestionText = aiQuestionText
|
||||
self.aiTaskKind = aiTaskKind
|
||||
self.managedRequestPurpose = managedRequestPurpose
|
||||
self.managedOOBEFeature = managedOOBEFeature
|
||||
self.aiThinkingEnabled = aiThinkingEnabled
|
||||
self.startDeadlineAt = startDeadlineAt
|
||||
self.processingDeadlineAt = processingDeadlineAt
|
||||
|
||||
@@ -17,6 +17,8 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
||||
public let aiTaskKind: ManagedGatewayTaskKind?
|
||||
/// Optional server-audited purpose for managed gateway billing policy.
|
||||
public let managedRequestPurpose: ManagedGatewayRequestPurpose?
|
||||
/// Required feature discriminator when `managedRequestPurpose == .oobe`.
|
||||
public let managedOOBEFeature: ManagedGatewayOOBEFeature?
|
||||
/// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
|
||||
public let aiThinkingEnabled: Bool?
|
||||
|
||||
@@ -31,6 +33,7 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
||||
aiQuestionText: String? = nil,
|
||||
aiTaskKind: ManagedGatewayTaskKind? = nil,
|
||||
managedRequestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||
managedOOBEFeature: ManagedGatewayOOBEFeature? = nil,
|
||||
aiThinkingEnabled: Bool? = nil
|
||||
) {
|
||||
self.mode = mode
|
||||
@@ -41,6 +44,7 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
||||
self.aiQuestionText = aiQuestionText
|
||||
self.aiTaskKind = aiTaskKind
|
||||
self.managedRequestPurpose = managedRequestPurpose
|
||||
self.managedOOBEFeature = managedOOBEFeature
|
||||
self.aiThinkingEnabled = aiThinkingEnabled
|
||||
}
|
||||
|
||||
@@ -62,6 +66,7 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
||||
conversationID: UUID,
|
||||
prefilledQuestion: String? = nil,
|
||||
taskKind: ManagedGatewayTaskKind = .aiQuestion,
|
||||
oobeFeature: ManagedGatewayOOBEFeature? = nil,
|
||||
thinkingEnabled: Bool? = nil
|
||||
) -> FlowUtteranceRequest {
|
||||
FlowUtteranceRequest(
|
||||
@@ -69,6 +74,8 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
||||
aiConversationID: conversationID,
|
||||
aiQuestionText: prefilledQuestion,
|
||||
aiTaskKind: taskKind,
|
||||
managedRequestPurpose: oobeFeature == nil ? nil : .oobe,
|
||||
managedOOBEFeature: oobeFeature,
|
||||
aiThinkingEnabled: thinkingEnabled
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
// OfficialSkillCatalog.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Validated, host-fetched official clipboard skills. The keyboard extension
|
||||
// only reads the last-known-good App Group snapshot and never performs network work.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct OfficialSkillLocalization: Codable, Equatable, Sendable {
|
||||
public let name: String
|
||||
public let summary: String
|
||||
public let prompt: String
|
||||
|
||||
public init(name: String, summary: String, prompt: String) {
|
||||
self.name = name
|
||||
self.summary = summary
|
||||
self.prompt = prompt
|
||||
}
|
||||
}
|
||||
|
||||
public struct OfficialSkillDefinition: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let systemImage: String
|
||||
public let sortOrder: Int
|
||||
public let kind: AIClipboardSkillKind
|
||||
public let thinkingEnabled: Bool
|
||||
public let localizations: [String: OfficialSkillLocalization]
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
systemImage: String,
|
||||
sortOrder: Int,
|
||||
kind: AIClipboardSkillKind,
|
||||
thinkingEnabled: Bool,
|
||||
localizations: [String: OfficialSkillLocalization]
|
||||
) {
|
||||
self.id = id
|
||||
self.systemImage = systemImage
|
||||
self.sortOrder = sortOrder
|
||||
self.kind = kind
|
||||
self.thinkingEnabled = thinkingEnabled
|
||||
self.localizations = localizations
|
||||
}
|
||||
|
||||
public func localization(
|
||||
language: AppUILanguage,
|
||||
preferredLanguages: [String] = Locale.preferredLanguages
|
||||
) -> OfficialSkillLocalization {
|
||||
localization(locale: language.resolvedLanguageCode(preferredLanguages: preferredLanguages))
|
||||
}
|
||||
|
||||
public func localization(locale: String) -> OfficialSkillLocalization {
|
||||
let normalized = locale.lowercased()
|
||||
let key = normalized.hasPrefix("zh") ? "zh-Hans" : "en"
|
||||
// A validated definition always contains both keys. Keep an English
|
||||
// fallback so a corrupt in-memory fixture cannot crash the extension.
|
||||
return localizations[key] ?? localizations["en"] ?? OfficialSkillLocalization(
|
||||
name: id,
|
||||
summary: "",
|
||||
prompt: ""
|
||||
)
|
||||
}
|
||||
|
||||
public func asClipboardSkill(
|
||||
language: AppUILanguage,
|
||||
preferredLanguages: [String] = Locale.preferredLanguages
|
||||
) -> AIClipboardSkill {
|
||||
asClipboardSkill(
|
||||
localization: localization(
|
||||
language: language,
|
||||
preferredLanguages: preferredLanguages
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public func asClipboardSkill(locale: String) -> AIClipboardSkill {
|
||||
asClipboardSkill(localization: localization(locale: locale))
|
||||
}
|
||||
|
||||
private func asClipboardSkill(localization: OfficialSkillLocalization) -> AIClipboardSkill {
|
||||
AIClipboardSkill(
|
||||
id: id,
|
||||
systemImage: systemImage,
|
||||
titleKey: "",
|
||||
cardTitleKey: "",
|
||||
descriptionKey: "",
|
||||
kind: kind,
|
||||
isDefault: false,
|
||||
customName: localization.name,
|
||||
customSummary: localization.summary,
|
||||
customPrompt: localization.prompt,
|
||||
thinkingEnabled: thinkingEnabled
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public enum OfficialSkillCatalogValidationError: Error, Equatable, Sendable {
|
||||
case unsupportedSchemaVersion(Int)
|
||||
case invalidRevision
|
||||
case invalidGeneratedAt
|
||||
case tooManySkills(maximum: Int)
|
||||
case invalidID(String)
|
||||
case duplicateID(String)
|
||||
case conflictsWithBuiltInID(String)
|
||||
case invalidSystemImage(String)
|
||||
case invalidSortOrder(String)
|
||||
case unsupportedKind(String)
|
||||
case invalidLocalizations(String)
|
||||
case emptyName(String, locale: String)
|
||||
case emptySummary(String, locale: String)
|
||||
case emptyPrompt(String, locale: String)
|
||||
case promptTooLong(String, locale: String, maximum: Int)
|
||||
}
|
||||
|
||||
public struct OfficialSkillCatalog: Codable, Equatable, Sendable {
|
||||
public static let supportedSchemaVersion = 1
|
||||
public static let maximumSkillCount = 100
|
||||
public static let maximumIDCharacters = 100
|
||||
public static let maximumSystemImageCharacters = 100
|
||||
public static let maximumSortOrder = 100_000
|
||||
public static let maximumNameCharacters = 40
|
||||
public static let maximumSummaryCharacters = 200
|
||||
public static let maximumPromptCharacters = 6_000
|
||||
|
||||
public let schemaVersion: Int
|
||||
public let revision: Int64
|
||||
public let generatedAt: String?
|
||||
public let skills: [OfficialSkillDefinition]
|
||||
/// Host wall clock for cache freshness. Absent in the server response.
|
||||
public var refreshedAt: Date?
|
||||
/// Last response ETag. Absent in the server response.
|
||||
public var etag: String?
|
||||
|
||||
public init(
|
||||
schemaVersion: Int = supportedSchemaVersion,
|
||||
revision: Int64,
|
||||
generatedAt: String? = nil,
|
||||
skills: [OfficialSkillDefinition],
|
||||
refreshedAt: Date? = nil,
|
||||
etag: String? = nil
|
||||
) {
|
||||
self.schemaVersion = schemaVersion
|
||||
self.revision = revision
|
||||
self.generatedAt = generatedAt
|
||||
self.skills = skills
|
||||
self.refreshedAt = refreshedAt
|
||||
self.etag = etag
|
||||
}
|
||||
|
||||
public static let empty = OfficialSkillCatalog(revision: 0, skills: [])
|
||||
|
||||
public func validated(
|
||||
builtInIDs: Set<String> = Set(AIClipboardSkillCatalog.catalog.map(\.id))
|
||||
) throws -> OfficialSkillCatalog {
|
||||
guard schemaVersion == Self.supportedSchemaVersion else {
|
||||
throw OfficialSkillCatalogValidationError.unsupportedSchemaVersion(schemaVersion)
|
||||
}
|
||||
guard revision >= 0 else {
|
||||
throw OfficialSkillCatalogValidationError.invalidRevision
|
||||
}
|
||||
if let generatedAt, Self.iso8601Date(from: generatedAt) == nil {
|
||||
throw OfficialSkillCatalogValidationError.invalidGeneratedAt
|
||||
}
|
||||
guard skills.count <= Self.maximumSkillCount else {
|
||||
throw OfficialSkillCatalogValidationError.tooManySkills(
|
||||
maximum: Self.maximumSkillCount
|
||||
)
|
||||
}
|
||||
|
||||
var seen = Set<String>()
|
||||
for skill in skills {
|
||||
guard Self.isValidID(skill.id) else {
|
||||
throw OfficialSkillCatalogValidationError.invalidID(skill.id)
|
||||
}
|
||||
guard seen.insert(skill.id).inserted else {
|
||||
throw OfficialSkillCatalogValidationError.duplicateID(skill.id)
|
||||
}
|
||||
guard !builtInIDs.contains(skill.id) else {
|
||||
throw OfficialSkillCatalogValidationError.conflictsWithBuiltInID(skill.id)
|
||||
}
|
||||
let image = skill.systemImage.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !image.isEmpty, image.count <= Self.maximumSystemImageCharacters else {
|
||||
throw OfficialSkillCatalogValidationError.invalidSystemImage(skill.id)
|
||||
}
|
||||
guard (0...Self.maximumSortOrder).contains(skill.sortOrder) else {
|
||||
throw OfficialSkillCatalogValidationError.invalidSortOrder(skill.id)
|
||||
}
|
||||
guard skill.kind == .transform else {
|
||||
throw OfficialSkillCatalogValidationError.unsupportedKind(skill.id)
|
||||
}
|
||||
guard Set(skill.localizations.keys) == ["zh-Hans", "en"] else {
|
||||
throw OfficialSkillCatalogValidationError.invalidLocalizations(skill.id)
|
||||
}
|
||||
for locale in ["zh-Hans", "en"] {
|
||||
guard let localization = skill.localizations[locale] else {
|
||||
throw OfficialSkillCatalogValidationError.invalidLocalizations(skill.id)
|
||||
}
|
||||
let name = localization.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let summary = localization.summary.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let prompt = localization.prompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !name.isEmpty, name.count <= Self.maximumNameCharacters else {
|
||||
throw OfficialSkillCatalogValidationError.emptyName(skill.id, locale: locale)
|
||||
}
|
||||
guard !summary.isEmpty,
|
||||
summary.count <= Self.maximumSummaryCharacters else {
|
||||
throw OfficialSkillCatalogValidationError.emptySummary(skill.id, locale: locale)
|
||||
}
|
||||
guard !prompt.isEmpty else {
|
||||
throw OfficialSkillCatalogValidationError.emptyPrompt(skill.id, locale: locale)
|
||||
}
|
||||
guard prompt.count <= Self.maximumPromptCharacters else {
|
||||
throw OfficialSkillCatalogValidationError.promptTooLong(
|
||||
skill.id,
|
||||
locale: locale,
|
||||
maximum: Self.maximumPromptCharacters
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return OfficialSkillCatalog(
|
||||
schemaVersion: schemaVersion,
|
||||
revision: revision,
|
||||
generatedAt: generatedAt,
|
||||
skills: skills.sorted {
|
||||
if $0.sortOrder == $1.sortOrder { return $0.id < $1.id }
|
||||
return $0.sortOrder < $1.sortOrder
|
||||
},
|
||||
refreshedAt: refreshedAt,
|
||||
etag: etag
|
||||
)
|
||||
}
|
||||
|
||||
public func resolvedSkills(
|
||||
language: AppUILanguage,
|
||||
preferredLanguages: [String] = Locale.preferredLanguages
|
||||
) -> [AIClipboardSkill] {
|
||||
skills.map {
|
||||
$0.asClipboardSkill(
|
||||
language: language,
|
||||
preferredLanguages: preferredLanguages
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func resolvedSkills(locale: String) -> [AIClipboardSkill] {
|
||||
skills.map { $0.asClipboardSkill(locale: locale) }
|
||||
}
|
||||
|
||||
private static func isValidID(_ id: String) -> Bool {
|
||||
guard id.hasPrefix("official."), id.count <= Self.maximumIDCharacters else {
|
||||
return false
|
||||
}
|
||||
let suffix = id.dropFirst("official.".count)
|
||||
guard !suffix.isEmpty else { return false }
|
||||
return suffix.unicodeScalars.allSatisfy { scalar in
|
||||
let value = scalar.value
|
||||
return (97...122).contains(value)
|
||||
|| (48...57).contains(value)
|
||||
|| value == 46
|
||||
|| value == 45
|
||||
|| value == 95
|
||||
}
|
||||
}
|
||||
|
||||
private static func iso8601Date(from value: String) -> Date? {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = formatter.date(from: value) { return date }
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
return formatter.date(from: value)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,18 @@ public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
|
||||
}
|
||||
|
||||
public let id: UUID
|
||||
/// Final text delivered to the host app and shown in history.
|
||||
public let text: String
|
||||
/// ASR transcript after deterministic correction but before LLM polishing.
|
||||
/// Stored for future speaking-style learning and intentionally not shown.
|
||||
public let prePolishText: String?
|
||||
/// Translation outputs must not be treated as same-language style examples.
|
||||
public let wasTranslation: Bool
|
||||
/// Style active when the final text was produced; nil for legacy or AI rows.
|
||||
public let polishStyleID: String?
|
||||
/// SHA-256 key into `SyncedSpeechHistory.polishStylePromptSnapshots`.
|
||||
/// Deduplication preserves the exact prompt without repeating it per row.
|
||||
public let polishStylePromptFingerprint: String?
|
||||
public let createdAt: Date
|
||||
/// Last content mutation. Legacy rows default to `createdAt`.
|
||||
public let modifiedAt: Date
|
||||
@@ -26,6 +37,10 @@ public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
text: String,
|
||||
prePolishText: String? = nil,
|
||||
wasTranslation: Bool = false,
|
||||
polishStyleID: String? = nil,
|
||||
polishStylePromptFingerprint: String? = nil,
|
||||
createdAt: Date = Date(),
|
||||
modifiedAt: Date? = nil,
|
||||
revision: Int64 = 0,
|
||||
@@ -34,6 +49,14 @@ public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
|
||||
) {
|
||||
self.id = id
|
||||
self.text = text
|
||||
let trimmedPrePolishText = prePolishText?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.prePolishText = trimmedPrePolishText?.isEmpty == false
|
||||
? trimmedPrePolishText
|
||||
: nil
|
||||
self.wasTranslation = wasTranslation
|
||||
self.polishStyleID = polishStyleID
|
||||
self.polishStylePromptFingerprint = polishStylePromptFingerprint
|
||||
self.createdAt = createdAt
|
||||
self.modifiedAt = modifiedAt ?? createdAt
|
||||
self.revision = revision
|
||||
@@ -45,6 +68,13 @@ public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
text = try container.decode(String.self, forKey: .text)
|
||||
prePolishText = try container.decodeIfPresent(String.self, forKey: .prePolishText)
|
||||
wasTranslation = try container.decodeIfPresent(Bool.self, forKey: .wasTranslation) ?? false
|
||||
polishStyleID = try container.decodeIfPresent(String.self, forKey: .polishStyleID)
|
||||
polishStylePromptFingerprint = try container.decodeIfPresent(
|
||||
String.self,
|
||||
forKey: .polishStylePromptFingerprint
|
||||
)
|
||||
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
modifiedAt = try container.decodeIfPresent(Date.self, forKey: .modifiedAt) ?? createdAt
|
||||
revision = try container.decodeIfPresent(Int64.self, forKey: .revision) ?? 0
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
// iCloud KVS payload for speech history. Tombstones and `clearedAt`
|
||||
// propagate single-entry deletes and "clear all" across devices.
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
||||
public static let schemaVersion = 3
|
||||
public static let schemaVersion = 5
|
||||
public static let kvsKey = "speechHistory.v2"
|
||||
public static let legacyKVSKey = "speechHistory.v1"
|
||||
public static let maxEntries = 300
|
||||
@@ -26,17 +27,24 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
||||
public var schemaVersion: Int
|
||||
public var updatedAt: Date
|
||||
public var entries: [SpeechHistoryEntry]
|
||||
/// Deduplicated historical style prompts keyed by SHA-256. Keeping prompt
|
||||
/// snapshots outside rows avoids multiplying a 6,000-character prompt by
|
||||
/// every dictation while still preserving the prompt used at that moment.
|
||||
public var polishStylePromptSnapshots: [String: String]
|
||||
/// Entry IDs deleted on any device, with deletion timestamps.
|
||||
public var deletedEntryIDs: [UUID: Date]
|
||||
/// Recent idempotency keys for keyboard-originated history mutations.
|
||||
public var appliedMutationIDs: [UUID]
|
||||
/// When set, entries created at or before this instant are excluded.
|
||||
public var clearedAt: Date?
|
||||
public static let maxPolishStylePromptSnapshots = 32
|
||||
public static let maxPolishStylePromptSnapshotCharacters = 96_000
|
||||
|
||||
public init(
|
||||
schemaVersion: Int = Self.schemaVersion,
|
||||
updatedAt: Date = Date(),
|
||||
entries: [SpeechHistoryEntry] = [],
|
||||
polishStylePromptSnapshots: [String: String] = [:],
|
||||
deletedEntryIDs: [UUID: Date] = [:],
|
||||
appliedMutationIDs: [UUID] = [],
|
||||
clearedAt: Date? = nil
|
||||
@@ -44,6 +52,7 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
||||
self.schemaVersion = schemaVersion
|
||||
self.updatedAt = updatedAt
|
||||
self.entries = entries
|
||||
self.polishStylePromptSnapshots = polishStylePromptSnapshots
|
||||
self.deletedEntryIDs = deletedEntryIDs
|
||||
self.appliedMutationIDs = appliedMutationIDs
|
||||
self.clearedAt = clearedAt
|
||||
@@ -54,6 +63,10 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
||||
schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
|
||||
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
||||
entries = try container.decodeIfPresent([SpeechHistoryEntry].self, forKey: .entries) ?? []
|
||||
polishStylePromptSnapshots = try container.decodeIfPresent(
|
||||
[String: String].self,
|
||||
forKey: .polishStylePromptSnapshots
|
||||
) ?? [:]
|
||||
if let map = try container.decodeIfPresent([UUID: Date].self, forKey: .deletedEntryIDs) {
|
||||
deletedEntryIDs = map
|
||||
} else if let legacyIDs = try container.decodeIfPresent([UUID].self, forKey: .deletedEntryIDs) {
|
||||
@@ -105,20 +118,30 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
||||
entries = Array(entries.prefix(maxEntries))
|
||||
}
|
||||
|
||||
return SyncedSpeechHistory(
|
||||
var snapshots = local.polishStylePromptSnapshots
|
||||
for (fingerprint, prompt) in remote.polishStylePromptSnapshots {
|
||||
snapshots[fingerprint] = snapshots[fingerprint] ?? prompt
|
||||
}
|
||||
|
||||
var merged = SyncedSpeechHistory(
|
||||
updatedAt: max(local.updatedAt, remote.updatedAt),
|
||||
entries: entries,
|
||||
polishStylePromptSnapshots: snapshots,
|
||||
deletedEntryIDs: deletedIDs,
|
||||
appliedMutationIDs: appliedMutationIDs,
|
||||
clearedAt: clearedAt
|
||||
)
|
||||
merged.prunePolishStylePromptSnapshots()
|
||||
return merged
|
||||
}
|
||||
|
||||
/// Trim to the newest `maxEntries` rows (call after local-only appends).
|
||||
public mutating func trimEntries() {
|
||||
guard entries.count > Self.maxEntries else { return }
|
||||
entries = Array(entries.sorted { $0.createdAt > $1.createdAt }.prefix(Self.maxEntries))
|
||||
updatedAt = Date()
|
||||
if entries.count > Self.maxEntries {
|
||||
entries = Array(entries.sorted { $0.createdAt > $1.createdAt }.prefix(Self.maxEntries))
|
||||
updatedAt = Date()
|
||||
}
|
||||
prunePolishStylePromptSnapshots()
|
||||
}
|
||||
|
||||
public mutating func pruneTombstonesIfNeeded() {
|
||||
@@ -162,19 +185,93 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
||||
_ candidate: SpeechHistoryEntry,
|
||||
over existing: SpeechHistoryEntry
|
||||
) -> SpeechHistoryEntry {
|
||||
let winner: SpeechHistoryEntry
|
||||
let fallback: SpeechHistoryEntry
|
||||
if candidate.revision != existing.revision {
|
||||
return candidate.revision > existing.revision ? candidate : existing
|
||||
(winner, fallback) = candidate.revision > existing.revision
|
||||
? (candidate, existing)
|
||||
: (existing, candidate)
|
||||
} else if candidate.modifiedAt != existing.modifiedAt {
|
||||
(winner, fallback) = candidate.modifiedAt > existing.modifiedAt
|
||||
? (candidate, existing)
|
||||
: (existing, candidate)
|
||||
} else {
|
||||
(winner, fallback) = candidate.createdAt >= existing.createdAt
|
||||
? (candidate, existing)
|
||||
: (existing, candidate)
|
||||
}
|
||||
if candidate.modifiedAt != existing.modifiedAt {
|
||||
return candidate.modifiedAt > existing.modifiedAt ? candidate : existing
|
||||
return preservingCorpusMetadata(of: winner, fallback: fallback)
|
||||
}
|
||||
|
||||
/// Older app versions decode and re-encode history without the v4/v5 corpus
|
||||
/// fields. A newer visible-text revision must win without erasing the only
|
||||
/// retained pre-polish sample.
|
||||
private static func preservingCorpusMetadata(
|
||||
of winner: SpeechHistoryEntry,
|
||||
fallback: SpeechHistoryEntry
|
||||
) -> SpeechHistoryEntry {
|
||||
let usesFallbackTranscript = winner.prePolishText == nil
|
||||
&& fallback.prePolishText != nil
|
||||
let prePolishText = winner.prePolishText ?? fallback.prePolishText
|
||||
let polishStyleID = winner.polishStyleID ?? fallback.polishStyleID
|
||||
let polishStylePromptFingerprint = winner.polishStylePromptFingerprint
|
||||
?? fallback.polishStylePromptFingerprint
|
||||
guard prePolishText != winner.prePolishText
|
||||
|| polishStyleID != winner.polishStyleID
|
||||
|| polishStylePromptFingerprint
|
||||
!= winner.polishStylePromptFingerprint else {
|
||||
return winner
|
||||
}
|
||||
return candidate.createdAt >= existing.createdAt ? candidate : existing
|
||||
return SpeechHistoryEntry(
|
||||
id: winner.id,
|
||||
text: winner.text,
|
||||
prePolishText: prePolishText,
|
||||
wasTranslation: usesFallbackTranscript
|
||||
? fallback.wasTranslation
|
||||
: winner.wasTranslation,
|
||||
polishStyleID: polishStyleID,
|
||||
polishStylePromptFingerprint: polishStylePromptFingerprint,
|
||||
createdAt: winner.createdAt,
|
||||
modifiedAt: winner.modifiedAt,
|
||||
revision: winner.revision,
|
||||
engineMode: winner.engineMode,
|
||||
source: winner.source
|
||||
)
|
||||
}
|
||||
|
||||
public static func polishStylePromptFingerprint(for prompt: String) -> String {
|
||||
SHA256.hash(data: Data(prompt.utf8))
|
||||
.map { String(format: "%02x", $0) }
|
||||
.joined()
|
||||
}
|
||||
|
||||
mutating func prunePolishStylePromptSnapshots() {
|
||||
var retained: [String: String] = [:]
|
||||
var retainedCharacters = 0
|
||||
let newestEntries = entries.sorted { $0.createdAt > $1.createdAt }
|
||||
|
||||
for entry in newestEntries {
|
||||
guard retained.count < Self.maxPolishStylePromptSnapshots,
|
||||
let fingerprint = entry.polishStylePromptFingerprint,
|
||||
retained[fingerprint] == nil,
|
||||
let prompt = polishStylePromptSnapshots[fingerprint] else {
|
||||
continue
|
||||
}
|
||||
guard retainedCharacters + prompt.count
|
||||
<= Self.maxPolishStylePromptSnapshotCharacters else {
|
||||
continue
|
||||
}
|
||||
retained[fingerprint] = prompt
|
||||
retainedCharacters += prompt.count
|
||||
}
|
||||
polishStylePromptSnapshots = retained
|
||||
}
|
||||
}
|
||||
|
||||
extension SyncedSpeechHistory {
|
||||
mutating func recordClearAll(at date: Date = Date()) {
|
||||
entries.removeAll()
|
||||
polishStylePromptSnapshots.removeAll()
|
||||
clearedAt = date
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user