checkpoint before checking out main

This commit is contained in:
Rocky
2026-08-25 13:42:00 +08:00
parent 57844ce615
commit dccafe6f9e
105 changed files with 4302 additions and 1323 deletions
@@ -70,6 +70,18 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
mergedCatalog.filter { !layout.isEnabled($0.id) }
}
public var skillManagementEnabledSkills: [AIClipboardSkill] {
enabledSkills.filter {
!AIClipboardSkillCatalog.hiddenFromSkillManagementIDs.contains($0.id)
}
}
public var skillManagementAvailableSkills: [AIClipboardSkill] {
availableSkills.filter {
!AIClipboardSkillCatalog.hiddenFromSkillManagementIDs.contains($0.id)
}
}
public func userSkill(id: String) -> AIUserSkill? {
userCatalog.skill(id: id)
}
@@ -146,6 +146,17 @@ public enum AIClipboardSkillCatalog: Sendable {
empathyReplyID,
businessReplyID
]
/// Contextual system actions remain available to semantic ranking but are
/// not user-managed entries in the host app's Skills catalog.
public static let hiddenFromSkillManagementIDs: Set<String> = [
replyID,
declineInvitationID,
empathyReplyID,
acceptInvitationID,
callPhoneID,
createContactID,
clarifyRequestID
]
public static let extractTodosID = "extractTodos"
public static let extractTodosShortcutName = "OSGExtractTodos"
public static let extractTodosResourceName = "OSGExtractTodos"
+37 -7
View File
@@ -86,7 +86,6 @@ public struct AppGroupStore: @unchecked Sendable {
public var translationEnabled: Bool { configuration.translationEnabled }
public var translationTargetLocaleId: String { configuration.translationTargetLocaleId }
public var handednessPreference: HandednessPreference { configuration.handednessPreference }
public var cursorDragNavigationEnabled: Bool { configuration.cursorDragNavigationEnabled }
public var keyboardHapticIntensity: KeyboardHapticIntensity { configuration.keyboardHapticIntensity }
public var polishIntensity: PolishIntensity { configuration.polishIntensity }
public var aiResponseLength: AIResponseLength { configuration.aiResponseLength }
@@ -140,6 +139,7 @@ public struct AppGroupStore: @unchecked Sendable {
public func setLocaleId(_ id: String) {
mutateConfiguration { $0.localeId = id }
AppGroupConfigDarwin.postConfigChanged()
}
public func setEngineMode(_ mode: String) {
@@ -180,11 +180,6 @@ public struct AppGroupStore: @unchecked Sendable {
AppGroupConfigDarwin.postConfigChanged()
}
public func setCursorDragNavigationEnabled(_ enabled: Bool) {
mutateConfiguration { $0.cursorDragNavigationEnabled = enabled }
AppGroupConfigDarwin.postConfigChanged()
}
public func setKeyboardHapticIntensity(_ intensity: KeyboardHapticIntensity) {
mutateConfiguration { $0.keyboardHapticIntensity = intensity }
AppGroupConfigDarwin.postConfigChanged()
@@ -438,6 +433,19 @@ public struct AppGroupStore: @unchecked Sendable {
}
// v6 persists canonical IDs for the consolidated reply, summary,
// and clarification skills. `sanitized` performs the mapping.
if storedMigrationVersion < 7 {
// Product baseline: install these text skills once for existing
// layouts. Skill-management visibility is a separate policy.
additionIDs.formUnion([
AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.translateID,
AIClipboardSkillCatalog.summarizeID,
AIClipboardSkillCatalog.declineInvitationID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.empathyReplyID,
AIClipboardSkillCatalog.organizeListID
])
}
let additions = catalog.map(\.id).filter {
additionIDs.contains($0) && !decoded.enabledIDs.contains($0)
}
@@ -463,7 +471,7 @@ public struct AppGroupStore: @unchecked Sendable {
}
}
private static let currentAgentSkillDefaultsMigrationVersion = 6
private static let currentAgentSkillDefaultsMigrationVersion = 7
private static func decodeUserSkillCatalog(from defaults: UserDefaults) -> AIUserSkillCatalog {
guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentUserSkillCatalog) else {
@@ -547,6 +555,28 @@ public struct AppGroupStore: @unchecked Sendable {
#endif
}
/// Applies a read-modify-write operation to the latest persisted dictionary.
///
/// Returning `nil` cancels the write. Callers must not mutate the dictionary
/// before returning `nil`.
@discardableResult
public func updatePersonalDictionary<Result>(
_ transform: (inout PersonalDictionary) -> Result?
) -> (dictionary: PersonalDictionary, result: Result)? {
var output: (dictionary: PersonalDictionary, result: Result)?
mutateConfiguration { config in
guard let result = transform(&config.personalDictionary) else { return }
output = (config.personalDictionary, result)
}
guard let output else { return nil }
AppGroupConfigDarwin.postConfigChanged()
#if os(iOS)
PersonalDictionaryRimeSync.scheduleAfterDictionaryChange()
#endif
return output
}
public func deletePersonalDictionaryEntry(id: UUID, at date: Date = Date()) {
mutateConfiguration { config in
config.personalDictionary.entries.removeAll { $0.id == id }
@@ -31,8 +31,8 @@ public enum ClipboardSkillSemanticRanker {
)
}
/// Returns only skills supported by current semantic evidence. No matching
/// label produces no recommendation instead of a fixed fallback row.
/// Returns semantically relevant skills and always keeps the generic Reply
/// action available as a safe fallback for accepted clipboard text.
public static func recommended(
skills: [AIClipboardSkill],
sourceText: String,
@@ -42,22 +42,42 @@ public enum ClipboardSkillSemanticRanker {
preferredLanguages: [String] = Locale.preferredLanguages
) -> [AIClipboardSkill] {
guard limit > 0 else { return [] }
let scores = relevanceScores(
var scores = relevanceScores(
sourceText: sourceText,
analysis: analysis,
preferredLanguages: preferredLanguages
)
let genericReply = skills.first { $0.id == AIClipboardSkillCatalog.replyID }
if genericReply != nil {
scores[AIClipboardSkillCatalog.replyID, default: 0] = max(
1,
scores[AIClipboardSkillCatalog.replyID, default: 0]
)
}
let relevant = skills.filter { scores[$0.id, default: 0] > 0 }
var selected: [AIClipboardSkill] = []
var replyCount = 0
var specializedReplyCount = 0
for skill in sorted(relevant, scores: scores) {
guard selected.count < limit else { break }
let mustReserveGenericReply = genericReply != nil
&& !selected.contains(where: { $0.id == AIClipboardSkillCatalog.replyID })
&& skill.id != AIClipboardSkillCatalog.replyID
let availableCount = limit - (mustReserveGenericReply ? 1 : 0)
guard selected.count < availableCount else { continue }
if skill.id == AIClipboardSkillCatalog.replyID {
selected.append(skill)
continue
}
if skill.supportsReplyStyle {
guard replyCount < maximumReplyRecommendations else { continue }
replyCount += 1
guard specializedReplyCount < maximumReplyRecommendations else { continue }
specializedReplyCount += 1
}
selected.append(skill)
}
if let genericReply,
selected.count < limit,
!selected.contains(where: { $0.id == genericReply.id }) {
selected.append(genericReply)
}
return selected
}
@@ -142,6 +142,12 @@ public final class KeyboardState: ObservableObject {
@Published public var returnKeyRole: ReturnKeyRole = .newline
/// Opt-in clipboard history capture (mirrored from App Group).
@Published public var clipboardHistoryEnabled: Bool = false
/// Current extension permission state. Clipboard history remains readable
/// without Full Access, but new system pasteboard entries cannot be captured.
@Published public var hasFullAccess: Bool = false
public var needsClipboardFullAccessHint: Bool {
clipboardHistoryEnabled && !hasFullAccess
}
/// Opt-in clipboard suggestion strip (requires history enabled).
@Published public var clipboardCandidateBarEnabled: Bool = false
/// Skills-tab order for clipboard chips. Empty hint carousel.
@@ -0,0 +1,120 @@
// PersonalDictionaryEntryService.swift
// OSGKeyboard · Shared
//
// Keeps every user-confirmed term on the same save, alias-generation,
// and cloud-sync path, regardless of which UI initiated the change.
import Foundation
@MainActor
public final class PersonalDictionaryEntryService {
public typealias AliasGeneration = @MainActor (String) async -> [String]
public typealias CloudPush = @MainActor (PersonalDictionary) async -> Void
public struct SavedEntry: Sendable {
public let entryID: UUID
public let term: String
public let source: PersonalDictionary.Entry.Source
public let dictionary: PersonalDictionary
public let shouldGenerateAliases: Bool
}
private let store: AppGroupStore
private let generateAliases: AliasGeneration
private let pushToCloud: CloudPush
public init(
store: AppGroupStore = AppGroupStore(),
aliasGeneration: AliasGeneration? = nil,
cloudPush: CloudPush? = nil
) {
self.store = store
if let aliasGeneration {
generateAliases = aliasGeneration
} else {
let generator = DictionaryAliasGenerator()
generateAliases = { term in
await generator.generateAliases(for: term)
}
}
pushToCloud = cloudPush ?? { dictionary in
try? await PersonalDictionaryCloudSync.shared.pushLocalIfEnabled(dictionary)
}
}
/// Saves immediately so alias generation never blocks the user's action.
public func saveEntry(
term: String,
existingID: UUID? = nil,
source: PersonalDictionary.Entry.Source,
minimumUsageCount: Int? = nil
) -> SavedEntry? {
var shouldGenerateAliases = false
guard let mutation = store.updatePersonalDictionary({ dictionary -> PersonalDictionary.Entry? in
let previousTerm = existingID.flatMap { id in
dictionary.entries.first(where: { $0.id == id })?.term
}
let termChanged = previousTerm.map {
$0.caseInsensitiveCompare(term) != .orderedSame
} ?? true
guard let saved = dictionary.upsert(
term: term,
existingID: existingID,
source: source
),
let index = dictionary.entries.firstIndex(where: { $0.id == saved.id }) else {
return nil
}
if let minimumUsageCount {
dictionary.entries[index].usageCount = max(
dictionary.entries[index].usageCount,
minimumUsageCount
)
}
dictionary.version += 1
shouldGenerateAliases = existingID == nil || termChanged
return dictionary.entries[index]
}) else {
return nil
}
return SavedEntry(
entryID: mutation.result.id,
term: mutation.result.term,
source: mutation.result.source,
dictionary: mutation.dictionary,
shouldGenerateAliases: shouldGenerateAliases
)
}
/// Finishes the asynchronous work against the latest dictionary snapshot.
///
/// The identity and canonical term checks prevent a late LLM response from
/// restoring a deleted entry or attaching aliases after the term changed.
@discardableResult
public func finishSaving(_ saved: SavedEntry) async -> PersonalDictionary {
await pushToCloud(store.personalDictionary)
guard saved.shouldGenerateAliases else { return store.personalDictionary }
let aliases = await generateAliases(saved.term)
guard !aliases.isEmpty else { return store.personalDictionary }
guard let mutation = store.updatePersonalDictionary({ dictionary -> UUID? in
guard let current = dictionary.entries.first(where: { $0.id == saved.entryID }),
current.term == saved.term,
current.source == saved.source else {
return nil
}
dictionary.updateAliases(for: saved.entryID, aliases: aliases)
dictionary.version += 1
return saved.entryID
}) else {
return store.personalDictionary
}
await pushToCloud(mutation.dictionary)
// Cloud sync may merge remote entries into local storage while pushing.
return store.personalDictionary
}
}
@@ -62,7 +62,7 @@ public struct PolishStyleLearningCorpus: Equatable, Sendable {
}
public enum PolishStyleLearningCorpusBuilder {
public static let requiredEffectiveCharacterCount = 5_000
public static let requiredEffectiveCharacterCount = 2_500
public static func build(
from entries: [SpeechHistoryEntry]
@@ -5,7 +5,10 @@
import Foundation
public enum SettingsDeepLink: String, Sendable {
public enum SettingsDeepLink: String, Sendable, Equatable {
case aiService
case speechRecognition
case textPolish
case clipboard
private static let pendingKey = "settings.pendingDeepLink"
@@ -19,11 +19,11 @@ public final class UsageStatisticsStore: ObservableObject {
dictationCharacterCount + translationCharacterCount + aiCharacterCount
}
/// Cross-device dictation characters per local day (`yyyy-MM-dd`), used by
/// the home page's 7-day chart.
/// the home page and dashboard usage visualizations.
@Published public private(set) var dailyDictationCharacters: [String: Int] = [:]
/// How many days of daily buckets to retain on disk. Well beyond the 7-day
/// chart window so a device that syncs in late still contributes recent days.
/// How many days of daily buckets to retain on disk. This preserves several
/// complete monthly views when a device syncs late.
private static let dailyRetentionDays = 90
public let defaults: UserDefaults
@@ -121,13 +121,18 @@ public final class UsageStatisticsStore: ObservableObject {
dailyDictationCharacters = payload.aggregatedDailyDictationCharacters
}
// MARK: - 7-day chart data
// MARK: - Daily chart data
/// One day's dictation total for the home page chart.
/// One day's dictation total for home and dashboard visualizations.
public struct DailyUsagePoint: Identifiable, Equatable, Sendable {
public let date: Date
public let value: Int
public var id: Date { date }
public init(date: Date, value: Int) {
self.date = date
self.value = value
}
}
/// The trailing 7 local days (oldest newest), zero-filled for days with no
@@ -151,6 +156,34 @@ public final class UsageStatisticsStore: ObservableObject {
return points
}
/// Every local-calendar day in the current month, including future days.
/// Missing buckets are zero-filled so the UI can always render a complete
/// calendar instead of changing shape as usage accumulates.
public var currentMonth: [DailyUsagePoint] {
Self.currentMonth(from: dailyDictationCharacters)
}
public static func currentMonth(
from daily: [String: Int],
now: Date = Date(),
calendar: Calendar = .current
) -> [DailyUsagePoint] {
let monthComponents = calendar.dateComponents([.year, .month], from: now)
guard let monthStart = calendar.date(from: monthComponents),
let dayRange = calendar.range(of: .day, in: .month, for: monthStart)
else { return [] }
return dayRange.compactMap { day in
guard let date = calendar.date(
byAdding: .day,
value: day - 1,
to: monthStart
) else { return nil }
let key = UsageStatisticsDayKey.key(for: date, calendar: calendar)
return DailyUsagePoint(date: date, value: daily[key] ?? 0)
}
}
/// One-time cleanup: the pre-fix code overwrote a device slice with the
/// cross-device *sum*, so every reload/record re-added the other devices'
/// totals and one slice ballooned to ~8× the true usage. We can't recover