feat(keyboard): add custom skills plus events and navigate Shortcuts

Ship user-defined Shortcut skills, companion Events/Navigate recipes, shared skill/style card chrome, and bump the build to 69.
This commit is contained in:
Rocky
2026-08-13 23:01:43 +08:00
parent 148ff807f2
commit 0a60006d2d
50 changed files with 11612 additions and 251 deletions
@@ -44,8 +44,10 @@ public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
}
/// Drops unknown IDs, unconfirmed export skills, and duplicates; caps at 8.
public func sanitized() -> AIAgentSkillLayout {
let known = Dictionary(uniqueKeysWithValues: AIClipboardSkillCatalog.catalog.map { ($0.id, $0) })
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 }
+177
View File
@@ -0,0 +1,177 @@
// AIUserSkill.swift
// OSGKeyboard · Shared
//
// User-created clipboard skills. Built-in skills stay in
// `AIClipboardSkillCatalog`; this catalog is persisted in App Group so the
// keyboard can resolve custom names, prompts, and Shortcut run names.
import Foundation
public struct AIUserSkill: Codable, Equatable, Identifiable, Sendable {
public let id: String
public var name: String
/// Card subtitle. Empty is allowed; the UI shows a generic fallback.
public var summary: String
public var systemImage: String
public var prompt: String
public var shortcutICloudURL: URL
/// Name used by `shortcuts://run-shortcut?name=`. Independent of `name`.
public var shortcutName: String
/// Per-skill reasoning. Built-in skills are always off; custom defaults off.
public var thinkingEnabled: Bool
public let createdAt: Date
public var updatedAt: Date
public init(
id: String = "user.\(UUID().uuidString.lowercased())",
name: String,
summary: String = "",
systemImage: String = AIUserSkillLimits.defaultSystemImage,
prompt: String,
shortcutICloudURL: URL,
shortcutName: String,
thinkingEnabled: Bool = false,
createdAt: Date = Date(),
updatedAt: Date? = nil
) {
self.id = id
self.name = name
self.summary = summary
self.systemImage = systemImage
self.prompt = prompt
self.shortcutICloudURL = shortcutICloudURL
self.shortcutName = shortcutName
self.thinkingEnabled = thinkingEnabled
self.createdAt = createdAt
self.updatedAt = updatedAt ?? createdAt
}
public var isUserCreated: Bool { id.hasPrefix("user.") }
public func asClipboardSkill() -> AIClipboardSkill {
AIClipboardSkill(
id: id,
systemImage: systemImage,
titleKey: "",
cardTitleKey: "",
descriptionKey: "",
kind: .export,
isDefault: false,
shortcutName: shortcutName,
shortcutICloudURL: shortcutICloudURL,
customName: name,
customSummary: summary,
customPrompt: prompt,
thinkingEnabled: thinkingEnabled
)
}
}
public enum AIUserSkillLimits {
public static let defaultSystemImage = "sparkles"
public static let maximumPromptCharacters = 6_000
public static let maximumNameCharacters = 40
public static let maximumSummaryCharacters = 200
public static let newPromptTemplate = """
请根据剪贴板内容完成以下操作。
只输出结果,不要解释或客套。
"""
/// Curated SF Symbols for the skill-icon picker.
public static let symbolChoices: [String] = [
"sparkles",
"wand.and.stars",
"text.badge.checkmark",
"checklist",
"calendar",
"envelope.fill",
"bubble.left.and.bubble.right.fill",
"character.bubble.fill",
"doc.text.magnifyingglass",
"arrowshape.turn.up.left.fill",
"lightbulb.fill",
"star.fill",
"heart.fill",
"flag.fill",
"tag.fill",
"folder.fill",
"list.bullet",
"square.and.pencil",
"scissors",
"globe",
"paperplane.fill",
"clock.fill",
"bell.fill",
"bookmark.fill",
"person.fill",
"link",
"number",
"at",
"tray.fill",
"quote.bubble.fill",
]
}
public enum AIUserSkillValidationError: Error, Equatable, Sendable {
case emptyName
case emptyPrompt
case emptyShortcutName
case invalidShortcutLink
case emptyIcon
case promptTooLong(maximum: Int)
}
public struct AIUserSkillCatalog: Codable, Equatable, Sendable {
public var entries: [AIUserSkill]
public init(entries: [AIUserSkill] = []) {
self.entries = entries.filter(\.isUserCreated)
}
public static let empty = AIUserSkillCatalog()
public func skill(id: String) -> AIUserSkill? {
entries.first { $0.id == id }
}
public mutating func upsert(_ skill: AIUserSkill, at date: Date = Date()) throws {
let name = skill.name.trimmingCharacters(in: .whitespacesAndNewlines)
let summary = skill.summary.trimmingCharacters(in: .whitespacesAndNewlines)
let prompt = skill.prompt.trimmingCharacters(in: .whitespacesAndNewlines)
let shortcutName = skill.shortcutName.trimmingCharacters(in: .whitespacesAndNewlines)
let icon = skill.systemImage.trimmingCharacters(in: .whitespacesAndNewlines)
guard skill.isUserCreated else { throw AIUserSkillValidationError.emptyName }
guard !name.isEmpty else { throw AIUserSkillValidationError.emptyName }
guard !prompt.isEmpty else { throw AIUserSkillValidationError.emptyPrompt }
guard prompt.count <= AIUserSkillLimits.maximumPromptCharacters else {
throw AIUserSkillValidationError.promptTooLong(
maximum: AIUserSkillLimits.maximumPromptCharacters
)
}
guard !shortcutName.isEmpty else { throw AIUserSkillValidationError.emptyShortcutName }
guard AIShortcutShareLink.isValid(skill.shortcutICloudURL) else {
throw AIUserSkillValidationError.invalidShortcutLink
}
guard !icon.isEmpty else { throw AIUserSkillValidationError.emptyIcon }
var saved = skill
saved.name = String(name.prefix(AIUserSkillLimits.maximumNameCharacters))
saved.summary = String(summary.prefix(AIUserSkillLimits.maximumSummaryCharacters))
saved.prompt = prompt
saved.shortcutName = shortcutName
saved.systemImage = icon
saved.thinkingEnabled = skill.thinkingEnabled
saved.updatedAt = date
if let index = entries.firstIndex(where: { $0.id == skill.id }) {
entries[index] = saved
} else {
entries.append(saved)
}
}
public mutating func remove(id: String) {
entries.removeAll { $0.id == id }
}
}
@@ -71,6 +71,8 @@ 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"
/// User-created clipboard skills (no cloud sync; App Group only).
public static let agentUserSkillCatalog = "config.aiAgentSkills.userCatalog.v1"
}
// MARK: - Stored fields
@@ -41,6 +41,8 @@ public struct FlowCommand: Codable, Equatable, Sendable {
public let aiConversationID: UUID?
/// Prefilled question used only by `.submitAIQuestion`.
public let aiQuestionText: String?
/// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
public let aiThinkingEnabled: Bool?
/// Absolute wall-clock deadlines survive extension reconstruction.
public let startDeadlineAt: TimeInterval?
public let processingDeadlineAt: TimeInterval?
@@ -60,6 +62,7 @@ public struct FlowCommand: Codable, Equatable, Sendable {
sourceHistoryEntryRevision: Int64? = nil,
aiConversationID: UUID? = nil,
aiQuestionText: String? = nil,
aiThinkingEnabled: Bool? = nil,
startDeadlineAt: TimeInterval? = nil,
processingDeadlineAt: TimeInterval? = nil
) {
@@ -77,6 +80,7 @@ public struct FlowCommand: Codable, Equatable, Sendable {
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
self.aiConversationID = aiConversationID
self.aiQuestionText = aiQuestionText
self.aiThinkingEnabled = aiThinkingEnabled
self.startDeadlineAt = startDeadlineAt
self.processingDeadlineAt = processingDeadlineAt
}
@@ -13,6 +13,8 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
public let aiConversationID: UUID?
/// When set with `.aiQuestion`, host skips ASR and answers this text.
public let aiQuestionText: String?
/// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
public let aiThinkingEnabled: Bool?
public static let dictation = FlowUtteranceRequest(mode: .dictation)
@@ -22,7 +24,8 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
sourceHistoryEntryID: UUID? = nil,
sourceHistoryEntryRevision: Int64? = nil,
aiConversationID: UUID? = nil,
aiQuestionText: String? = nil
aiQuestionText: String? = nil,
aiThinkingEnabled: Bool? = nil
) {
self.mode = mode
self.editSourceText = editSourceText
@@ -30,6 +33,7 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
self.aiConversationID = aiConversationID
self.aiQuestionText = aiQuestionText
self.aiThinkingEnabled = aiThinkingEnabled
}
public static func editLastInput(
@@ -48,12 +52,14 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
public static func aiQuestion(
conversationID: UUID,
prefilledQuestion: String? = nil
prefilledQuestion: String? = nil,
thinkingEnabled: Bool? = nil
) -> FlowUtteranceRequest {
FlowUtteranceRequest(
mode: .aiQuestion,
aiConversationID: conversationID,
aiQuestionText: prefilledQuestion
aiQuestionText: prefilledQuestion,
aiThinkingEnabled: thinkingEnabled
)
}
}
@@ -0,0 +1,148 @@
// AIAddressExtraction.swift
// OSGKeyboard · Shared
//
// Parses the LLM's navigate-skill reply into one origin|destination line.
// Fail closed: empty / NONE / no destination never become a run. Only the
// first valid line is kept opening several map apps is not useful.
import Foundation
public struct AIMapRoute: Equatable, Sendable {
/// Nil means "current location" in the map app.
public let origin: String?
public let destination: String
public init(origin: String?, destination: String) {
self.origin = origin
self.destination = destination
}
}
public enum AIAddressExtraction: Sendable {
/// Canonical line: origin|destination. Empty origin keeps the pipe.
public static let fieldSeparator: Character = "|"
private static let emptyTokens: Set<String> = [
"none", "no", "n/a", "na", "nil", "null",
"", "没有", "没有地址", "没有地点", "无地址", "无地点",
"没有可导航的地点", "没有可导航的地址",
"no address", "no addresses", "no location", "no locations",
"no place", "no places", "no destination",
]
/// Lines to send to the host. Empty do not run the companion Shortcut.
public static func lines(
from raw: String,
sourceClipboard: String? = nil
) -> [String] {
guard let route = route(from: raw, sourceClipboard: sourceClipboard) else {
return []
}
return [encode(route)]
}
public static func route(
from raw: String,
sourceClipboard: String? = nil
) -> AIMapRoute? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if isEmptyToken(trimmed) { return nil }
for line in trimmed.components(separatedBy: .newlines) {
guard let route = parseLine(line) else { continue }
if isWholeClipboardEcho(route, source: sourceClipboard) {
return nil
}
return route
}
return nil
}
public static func encode(_ route: AIMapRoute) -> String {
"\(route.origin ?? "")|\(route.destination)"
}
// MARK: - Line
private static func parseLine(_ line: String) -> AIMapRoute? {
let stripped = stripBullet(line)
guard !stripped.isEmpty, !isEmptyToken(stripped) else { return nil }
let parts = stripped
.split(separator: fieldSeparator, omittingEmptySubsequences: false)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
let originRaw: String
let destinationRaw: String
if parts.count == 1 {
originRaw = ""
destinationRaw = parts[0]
} else {
originRaw = parts[0]
var destParts = Array(parts[1...])
while destParts.last?.isEmpty == true {
destParts.removeLast()
}
destinationRaw = destParts
.joined(separator: String(fieldSeparator))
.trimmingCharacters(in: .whitespacesAndNewlines)
}
guard let destination = sanitizedPlace(destinationRaw) else { return nil }
let origin = sanitizedPlace(originRaw)
if let origin, origin.caseInsensitiveCompare(destination) == .orderedSame {
return AIMapRoute(origin: nil, destination: destination)
}
return AIMapRoute(origin: origin, destination: destination)
}
/// Drop URLs and empty / NONE tokens. Place names stay as-is.
private static func sanitizedPlace(_ raw: String) -> String? {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, !isEmptyToken(text) else { return nil }
if text.contains("://") { return nil }
return text
}
private static func isEmptyToken(_ text: String) -> Bool {
let folded = text
.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "。.!"))
.lowercased()
return emptyTokens.contains(folded)
}
private static func stripBullet(_ line: String) -> String {
var text = line.trimmingCharacters(in: .whitespacesAndNewlines)
let prefixes = ["- ", "* ", "", ""]
for prefix in prefixes where text.hasPrefix(prefix) {
text = String(text.dropFirst(prefix.count))
.trimmingCharacters(in: .whitespacesAndNewlines)
}
if let dotted = text.range(of: #"^\d+[\.\)、]\s*"#, options: .regularExpression) {
text = String(text[dotted.upperBound...])
.trimmingCharacters(in: .whitespacesAndNewlines)
}
return text
}
/// One long destination that is essentially the clipboard body is not a place.
private static func isWholeClipboardEcho(_ route: AIMapRoute, source: String?) -> Bool {
guard let source, source.count > 80, route.destination.count > 80 else {
return false
}
let a = collapse(route.destination)
let b = collapse(source)
guard !a.isEmpty, !b.isEmpty else { return false }
if a == b { return true }
return a.contains(b) || b.contains(a)
}
private static func collapse(_ text: String) -> String {
text.components(separatedBy: .whitespacesAndNewlines)
.filter { !$0.isEmpty }
.joined(separator: " ")
.lowercased()
}
}
@@ -12,46 +12,65 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
public static let shared = AIAgentSkillLayoutStore()
@Published public private(set) var layout: AIAgentSkillLayout
@Published public private(set) var userCatalog: AIUserSkillCatalog
private let defaults: UserDefaults?
private let persist: (AIAgentSkillLayout) -> Void
private let load: () -> AIAgentSkillLayout
private let persistLayout: (AIAgentSkillLayout) -> Void
private let persistUserCatalog: (AIUserSkillCatalog) -> Void
private let loadLayout: () -> AIAgentSkillLayout
private let loadUserCatalog: () -> AIUserSkillCatalog
public init(defaults: UserDefaults? = nil) {
if let defaults {
self.defaults = defaults
self.load = { AppGroupStore(defaults: defaults).agentSkillLayout }
self.persist = { AppGroupStore(defaults: defaults).setAgentSkillLayout($0) }
self.loadUserCatalog = { AppGroupStore(defaults: defaults).agentUserSkillCatalog }
self.persistUserCatalog = { AppGroupStore(defaults: defaults).setAgentUserSkillCatalog($0) }
self.loadLayout = { AppGroupStore(defaults: defaults).agentSkillLayout }
self.persistLayout = { AppGroupStore(defaults: defaults).setAgentSkillLayout($0) }
} else {
self.defaults = nil
self.load = { AppGroupStore().agentSkillLayout }
self.persist = { AppGroupStore().setAgentSkillLayout($0) }
self.loadUserCatalog = { AppGroupStore().agentUserSkillCatalog }
self.persistUserCatalog = { AppGroupStore().setAgentUserSkillCatalog($0) }
self.loadLayout = { AppGroupStore().agentSkillLayout }
self.persistLayout = { AppGroupStore().setAgentSkillLayout($0) }
}
self.layout = self.load()
self.userCatalog = self.loadUserCatalog()
self.layout = self.loadLayout()
}
public func reload() {
layout = load()
userCatalog = loadUserCatalog()
layout = loadLayout()
}
public var mergedCatalog: [AIClipboardSkill] {
AIClipboardSkillCatalog.all(userCatalog: userCatalog)
}
public var enabledSkills: [AIClipboardSkill] {
AIClipboardSkillCatalog.visible(enabledIDs: layout.enabledIDs)
AIClipboardSkillCatalog.visible(
enabledIDs: layout.enabledIDs,
userCatalog: userCatalog
)
}
public var availableSkills: [AIClipboardSkill] {
AIClipboardSkillCatalog.catalog.filter { !layout.isEnabled($0.id) }
mergedCatalog.filter { !layout.isEnabled($0.id) }
}
public func userSkill(id: String) -> AIUserSkill? {
userCatalog.skill(id: id)
}
@discardableResult
public func enable(_ id: String) -> AIAgentSkillEnableResult {
let current = layout.sanitized()
guard let skill = AIClipboardSkillCatalog.skill(id: id) else { return .unknown }
let current = layout.sanitized(catalog: mergedCatalog)
guard let skill = AIClipboardSkillCatalog.skill(id: id, userCatalog: userCatalog) else {
return .unknown
}
if current.isEnabled(id) { return .alreadyEnabled }
if skill.requiresShortcut, !current.hasConfirmedShortcut(id) {
return .needsShortcut
}
if current.isFull { return .full }
commit(
commitLayout(
AIAgentSkillLayout(
enabledIDs: current.enabledIDs + [id],
confirmedShortcutIDs: current.confirmedShortcutIDs
@@ -63,8 +82,8 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
/// Drops the keyboard slot only. Companion Shortcuts stay installed;
/// the user deletes them in the Shortcuts app if they want them gone.
public func disable(_ id: String) {
let current = layout.sanitized()
commit(
let current = layout.sanitized(catalog: mergedCatalog)
commitLayout(
AIAgentSkillLayout(
enabledIDs: current.enabledIDs.filter { $0 != id },
confirmedShortcutIDs: current.confirmedShortcutIDs
@@ -75,27 +94,34 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
/// Marks the companion Shortcut as added, then tries to occupy a slot.
@discardableResult
public func confirmShortcutAndEnable(_ id: String) -> AIAgentSkillEnableResult {
guard let skill = AIClipboardSkillCatalog.skill(id: id), skill.requiresShortcut else {
guard let skill = AIClipboardSkillCatalog.skill(id: id, userCatalog: userCatalog),
skill.requiresShortcut else {
return .unknown
}
var current = layout.sanitized()
var current = layout.sanitized(catalog: mergedCatalog)
if !current.confirmedShortcutIDs.contains(id) {
current.confirmedShortcutIDs.append(id)
}
commit(current)
commitLayout(current)
return enable(id)
}
public func moveEnabled(id draggedID: String, onto targetID: String) {
var ids = layout.sanitized().enabledIDs
guard let from = ids.firstIndex(of: draggedID),
let to = ids.firstIndex(of: targetID),
from != to else { return }
let ids = layout.sanitized(catalog: mergedCatalog).enabledIDs
guard let to = ids.firstIndex(of: targetID) else { return }
moveEnabled(id: draggedID, toIndex: to)
}
public func moveEnabled(id draggedID: String, toIndex: Int) {
var ids = layout.sanitized(catalog: mergedCatalog).enabledIDs
guard let from = ids.firstIndex(of: draggedID), !ids.isEmpty else { return }
let to = min(max(toIndex, 0), ids.count - 1)
guard from != to else { return }
ids.move(
fromOffsets: IndexSet(integer: from),
toOffset: to > from ? to + 1 : to
)
commit(
commitLayout(
AIAgentSkillLayout(
enabledIDs: ids,
confirmedShortcutIDs: layout.confirmedShortcutIDs
@@ -103,8 +129,47 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
)
}
private func commit(_ layout: AIAgentSkillLayout) {
persist(layout)
self.layout = load()
public func saveUserSkill(_ skill: AIUserSkill) throws {
let previousURL = userCatalog.skill(id: skill.id)?.shortcutICloudURL
var catalog = userCatalog
try catalog.upsert(skill)
commitUserCatalog(catalog)
if previousURL != nil, previousURL != skill.shortcutICloudURL {
dropShortcutConfirmation(for: skill.id)
}
}
public func deleteUserSkill(id: String) {
var catalog = userCatalog
catalog.remove(id: id)
commitUserCatalog(catalog)
let current = layout.sanitized(catalog: mergedCatalog)
commitLayout(
AIAgentSkillLayout(
enabledIDs: current.enabledIDs.filter { $0 != id },
confirmedShortcutIDs: current.confirmedShortcutIDs.filter { $0 != id }
)
)
}
private func dropShortcutConfirmation(for id: String) {
let current = layout.sanitized(catalog: mergedCatalog)
commitLayout(
AIAgentSkillLayout(
enabledIDs: current.enabledIDs.filter { $0 != id },
confirmedShortcutIDs: current.confirmedShortcutIDs.filter { $0 != id }
)
)
}
private func commitLayout(_ layout: AIAgentSkillLayout) {
persistLayout(layout)
self.layout = loadLayout()
}
private func commitUserCatalog(_ catalog: AIUserSkillCatalog) {
persistUserCatalog(catalog)
userCatalog = loadUserCatalog()
layout = loadLayout()
}
}
+143 -11
View File
@@ -30,8 +30,17 @@ public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
public let shortcutName: String?
/// Optional `icloud.com/shortcuts/` share URL. Nil open the bundled file.
public let shortcutICloudURL: URL?
/// Bundled `.shortcut` resource name without extension. Nil no file fallback.
public let shortcutResourceName: String?
/// User-created skills store display copy here instead of localization keys.
public let customName: String?
public let customSummary: String?
public let customPrompt: String?
/// Built-in skills are always false. Custom skills default off.
public let thinkingEnabled: Bool
public var requiresShortcut: Bool { kind == .export }
public var isUserCreated: Bool { id.hasPrefix("user.") }
public init(
id: String,
@@ -42,7 +51,12 @@ public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
kind: AIClipboardSkillKind,
isDefault: Bool,
shortcutName: String? = nil,
shortcutICloudURL: URL? = nil
shortcutICloudURL: URL? = nil,
shortcutResourceName: String? = nil,
customName: String? = nil,
customSummary: String? = nil,
customPrompt: String? = nil,
thinkingEnabled: Bool = false
) {
self.id = id
self.systemImage = systemImage
@@ -53,6 +67,11 @@ public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
self.isDefault = isDefault
self.shortcutName = shortcutName
self.shortcutICloudURL = shortcutICloudURL
self.shortcutResourceName = shortcutResourceName
self.customName = customName
self.customSummary = customSummary
self.customPrompt = customPrompt
self.thinkingEnabled = id.hasPrefix("user.") ? thinkingEnabled : false
}
}
@@ -63,8 +82,20 @@ public enum AIClipboardSkillCatalog: Sendable {
public static let extractTodosID = "extractTodos"
public static let extractTodosShortcutName = "OSG · 提取待办"
public static let extractTodosShortcutICloudURL = URL(
string: "https://www.icloud.com/shortcuts/520317da7ae74759b64d5fb069c71f81"
string: "https://www.icloud.com/shortcuts/65bf33ba4206484ba78d582eaf1e9c44"
)!
public static let extractTodosResourceName = "OSGExtractTodos"
public static let extractEventsID = "extractEvents"
public static let extractEventsShortcutName = "OSG · 提取日程"
public static let extractEventsShortcutICloudURL = URL(
string: "https://www.icloud.com/shortcuts/1f4afcf7ee22400cbf84e319d969aadf"
)!
public static let extractEventsResourceName = "OSGExtractEvents"
public static let navigateID = "navigate"
public static let navigateShortcutName = "OSG · 导航"
public static let navigateResourceName = "OSGNavigate"
/// Full built-in catalog, in a stable display order for the Skills tab.
public static let catalog: [AIClipboardSkill] = [
@@ -104,35 +135,76 @@ public enum AIClipboardSkillCatalog: Sendable {
kind: .export,
isDefault: false,
shortcutName: extractTodosShortcutName,
shortcutICloudURL: extractTodosShortcutICloudURL
shortcutICloudURL: extractTodosShortcutICloudURL,
shortcutResourceName: extractTodosResourceName
),
AIClipboardSkill(
id: extractEventsID,
systemImage: "calendar",
titleKey: "keyboard.ai.skill.extractEvents",
cardTitleKey: "skills.extractEvents.name",
descriptionKey: "skills.extractEvents.description",
kind: .export,
isDefault: false,
shortcutName: extractEventsShortcutName,
shortcutICloudURL: extractEventsShortcutICloudURL,
shortcutResourceName: extractEventsResourceName
),
AIClipboardSkill(
id: navigateID,
systemImage: "arrow.triangle.turn.up.right.diamond.fill",
titleKey: "keyboard.ai.skill.navigate",
cardTitleKey: "skills.navigate.name",
descriptionKey: "skills.navigate.description",
kind: .export,
isDefault: false,
shortcutName: navigateShortcutName,
shortcutResourceName: navigateResourceName
),
]
/// Legacy alias: the three default transform skills used to be the whole list.
public static let builtIn: [AIClipboardSkill] = catalog
public static func skill(id: String) -> AIClipboardSkill? {
catalog.first { $0.id == id }
public static func all(userCatalog: AIUserSkillCatalog = .empty) -> [AIClipboardSkill] {
catalog + userCatalog.entries.map { $0.asClipboardSkill() }
}
public static func skill(
id: String,
userCatalog: AIUserSkillCatalog = .empty
) -> AIClipboardSkill? {
catalog.first { $0.id == id } ?? userCatalog.skill(id: id)?.asClipboardSkill()
}
/// `enabledIDs` is the Skills-tab order. `nil` keeps the default three.
/// An explicit empty array shows no chips (carousel fallback).
public static func visible(enabledIDs: [String]? = nil) -> [AIClipboardSkill] {
public static func visible(
enabledIDs: [String]? = nil,
userCatalog: AIUserSkillCatalog = .empty
) -> [AIClipboardSkill] {
let ids = enabledIDs ?? AIAgentSkillLayout.defaultEnabledIDs
guard !ids.isEmpty else { return [] }
let byID = Dictionary(uniqueKeysWithValues: catalog.map { ($0.id, $0) })
let byID = Dictionary(uniqueKeysWithValues: all(userCatalog: userCatalog).map { ($0.id, $0) })
return ids.compactMap { byID[$0] }
}
public static func instruction(
for skill: AIClipboardSkill,
locale: String,
translationTargetLocaleId: String
translationTargetLocaleId: String,
now: Date = Date()
) -> String {
instruction(
if let custom = skill.customPrompt?
.trimmingCharacters(in: .whitespacesAndNewlines),
!custom.isEmpty {
return custom
}
return instruction(
skillID: skill.id,
locale: locale,
translationTargetLocaleId: translationTargetLocaleId
translationTargetLocaleId: translationTargetLocaleId,
now: now
)
}
@@ -159,7 +231,8 @@ public enum AIClipboardSkillCatalog: Sendable {
public static func instruction(
skillID: String,
locale: String,
translationTargetLocaleId: String
translationTargetLocaleId: String,
now: Date = Date()
) -> String {
let zh = locale == "zh"
switch skillID {
@@ -188,6 +261,10 @@ public enum AIClipboardSkillCatalog: Sendable {
If there are no actionable tasks, output NONE and nothing else. Do not treat the whole clipboard as one task.
If the clipboard itself is already one short task (for example "buy milk"), output that single line.
"""
case extractEventsID:
return eventInstruction(zh: zh, now: now)
case navigateID:
return navigateInstruction(zh: zh)
default:
return zh
? "请根据剪贴板内容完成用户选择的操作。"
@@ -195,6 +272,61 @@ public enum AIClipboardSkillCatalog: Sendable {
}
}
private static func navigateInstruction(zh: Bool) -> String {
if zh {
return """
请从剪贴板提取明确的地点用于导航。只输出一行,两段用 | 分隔:起点|终点
从当前位置出发则起点留空,但保留竖线,例如 |朝阳区酒仙桥路10号
两点都写了则两侧都填,例如 北京南站|三里屯太古里
可以是完整地址或常用地名。不要编号、不要解释、不要多行、不要链接。
若有多条地址,只输出最明确的一条。
没有可导航的地点时,只输出 NONE。不要把整段原文当成一个地点。
"""
}
return """
Extract one place for turn-by-turn navigation from the clipboard. One line, two fields separated by | : origin|destination
Leave origin empty when starting from the current location, but keep the pipe, for example |10 Jiuxianqiao Road
Fill both sides when the source names two places, for example Beijing South|Sanlitun Taikoo Li
A full address or a well-known place name is fine. No numbering, commentary, extra lines, or URLs.
If there are several addresses, output only the clearest one.
If there is no navigable place, output NONE and nothing else. Do not treat the whole clipboard as one place.
"""
}
/// Clock context so relative phrases (tomorrow, 3pm) resolve to local time.
private static func eventInstruction(zh: Bool, now: Date) -> String {
let clock = clockContext(now: now, zh: zh)
if zh {
return """
\(clock)
请从剪贴板提取明确的日程。每条一行,四段用 | 分隔:开始|结束|标题|地点
开始有钟点用 YYYY-MM-DD HH:mm;只有日期(全天)用 YYYY-MM-DD。没有结束时间或地点则该段留空,但保留竖线。标题中不要出现 |。最多 20 条。不要编号、不要解释。
只有时刻、没有日期时,使用今天的日期。日期和时间都没有的条目不要输出。
原文写了结束时间就填写结束段,否则留空(后续按 1 小时处理)。原文有地点就填写地点段。
若没有任何带日期或时间的日程,只输出 NONE,不要把整段原文当成一条日程。
"""
}
return """
\(clock)
Extract explicit calendar events from the clipboard. One event per line, four fields separated by | : start|end|title|location
Timed start uses YYYY-MM-DD HH:mm; date-only (all-day) uses YYYY-MM-DD. Leave end or location empty when unknown, but keep the pipes. Do not put | in the title. Maximum 20 lines. No numbering or commentary.
Time without a date uses today. Skip items that have neither a date nor a time.
Fill the end field when the source gives an end time; otherwise leave it empty (treated as 1 hour). Fill location when the source names a place.
If there are no events with a date or time, output NONE and nothing else. Do not treat the whole clipboard as one event.
"""
}
private static func clockContext(now: Date, zh: Bool) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: zh ? "zh_CN" : "en_US_POSIX")
formatter.timeZone = TimeZone.current
formatter.dateFormat = zh ? "yyyy年M月d日EEEE HH:mm" : "EEEE, d MMMM yyyy, HH:mm"
let stamp = formatter.string(from: now)
return zh
? "现在是\(stamp)(设备本地时区)。"
: "It is now \(stamp) (device local timezone)."
}
/// Uses the keyboard translation target when set; otherwise Chinese English.
private static func translateInstruction(
locale: String,
@@ -0,0 +1,279 @@
// AIEventExtraction.swift
// OSGKeyboard · Shared
//
// Parses the LLM's extract-events reply into Shortcut lines. Fail closed:
// empty / NONE / lines without a parseable start never become a run.
// Canonical line: start|end|title|location
// All-day uses end token ALLDAY so the companion Shortcut can branch.
import Foundation
public enum AIEventExtraction: Sendable {
public static let maximumItems = 20
public static let defaultDuration: TimeInterval = 3600
/// End-field sentinel for all-day events (Shortcut If equals this).
public static let allDaySentinel = "ALLDAY"
private static let emptyTokens: Set<String> = [
"none", "no", "n/a", "na", "nil", "null",
"", "没有", "没有日程", "没有事件", "无日程",
"没有日期", "没有时间", "没有日期或时间", "没有日期和时间",
"no events", "no event", "no calendar events",
"no date", "no time", "no date or time", "no date and time",
]
/// Lines to send to the companion Shortcut. Empty do not run it.
public static func lines(
from raw: String,
sourceClipboard: String? = nil,
now: Date = Date(),
calendar: Calendar = .current
) -> [String] {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
if isEmptyToken(trimmed) { return [] }
var seen = Set<String>()
var items: [String] = []
for line in trimmed.components(separatedBy: .newlines) {
guard let encoded = encodeLine(
line,
now: now,
calendar: calendar
) else { continue }
let key = encoded.lowercased()
guard seen.insert(key).inserted else { continue }
items.append(encoded)
if items.count == maximumItems { break }
}
if items.count == 1, isWholeClipboardEcho(items[0], source: sourceClipboard) {
return []
}
return items
}
// MARK: - Line
private static func encodeLine(
_ line: String,
now: Date,
calendar: Calendar
) -> String? {
let fields = splitFields(line)
guard fields.count >= 2 else { return nil }
let startRaw = fields[0]
let endRaw: String
let titleRaw: String
let locationRaw: String
switch fields.count {
case 2:
endRaw = ""
titleRaw = fields[1]
locationRaw = ""
case 3:
// 3 fields: start|end|title if segment 2 is a time/empty;
// otherwise start|title|location.
if fields[1].isEmpty || parseInstant(fields[1], on: now, calendar: calendar) != nil {
endRaw = fields[1]
titleRaw = fields[2]
locationRaw = ""
} else {
endRaw = ""
titleRaw = fields[1]
locationRaw = fields[2]
}
default:
endRaw = fields[1]
titleRaw = fields[2]
locationRaw = fields[3]
}
let title = stripBullet(titleRaw)
guard !title.isEmpty, !isEmptyToken(title) else { return nil }
let location = locationRaw.trimmingCharacters(in: .whitespacesAndNewlines)
guard let start = parseInstant(startRaw, on: now, calendar: calendar) else {
return nil
}
switch start {
case .allDay(let day):
return encode(
start: formatDay(day, calendar: calendar),
end: allDaySentinel,
title: title,
location: location
)
case .timed(let startDate, _):
let endDate = resolveEnd(
endRaw,
start: startDate,
calendar: calendar
)
return encode(
start: formatMinute(startDate, calendar: calendar),
end: formatMinute(endDate, calendar: calendar),
title: title,
location: location
)
}
}
/// Split on `|` and keep at most 4 fields; extra segments join into location.
private static func splitFields(_ line: String) -> [String] {
let parts = line
.split(separator: "|", omittingEmptySubsequences: false)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
guard parts.count > 4 else { return parts }
let location = parts[3...].joined(separator: "|")
return [parts[0], parts[1], parts[2], location]
}
private static func encode(start: String, end: String, title: String, location: String) -> String {
"\(start)|\(end)|\(title)|\(location)"
}
// MARK: - Time
private enum Instant {
case allDay(Date)
case timed(Date, timeOnly: Bool)
}
private static func parseInstant(
_ raw: String,
on day: Date,
calendar: Calendar
) -> Instant? {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
if text.caseInsensitiveCompare(allDaySentinel) == .orderedSame {
return .allDay(calendar.startOfDay(for: day))
}
for format in ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm"] {
if let date = date(from: text, format: format, calendar: calendar) {
return .timed(date, timeOnly: false)
}
}
if let date = date(from: text, format: "yyyy-MM-dd", calendar: calendar) {
return .allDay(calendar.startOfDay(for: date))
}
for format in ["HH:mm:ss", "HH:mm", "H:mm"] {
if let parsed = date(from: text, format: format, calendar: calendar) {
let parts = calendar.dateComponents([.hour, .minute, .second], from: parsed)
guard let combined = calendar.date(
bySettingHour: parts.hour ?? 0,
minute: parts.minute ?? 0,
second: 0,
of: day
) else { return nil }
return .timed(combined, timeOnly: true)
}
}
return nil
}
private static func resolveEnd(
_ raw: String,
start: Date,
calendar: Calendar
) -> Date {
let fallback = start.addingTimeInterval(defaultDuration)
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty,
trimmed.caseInsensitiveCompare(allDaySentinel) != .orderedSame else {
return fallback
}
guard let parsed = parseInstant(trimmed, on: start, calendar: calendar) else {
return fallback
}
let end: Date
switch parsed {
case .allDay(let day):
// Date-only end with a timed start: use that calendar day at 23:59.
end = calendar.date(bySettingHour: 23, minute: 59, second: 0, of: day) ?? fallback
case .timed(let date, let timeOnly):
if timeOnly, date <= start {
end = calendar.date(byAdding: .day, value: 1, to: date) ?? fallback
} else {
end = date
}
}
return end <= start ? fallback : end
}
private static func date(from text: String, format: String, calendar: Calendar) -> Date? {
let formatter = DateFormatter()
formatter.calendar = calendar
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = calendar.timeZone
formatter.isLenient = false
formatter.dateFormat = format
return formatter.date(from: text)
}
private static func formatDay(_ date: Date, calendar: Calendar) -> String {
format(date, "yyyy-MM-dd", calendar: calendar)
}
private static func formatMinute(_ date: Date, calendar: Calendar) -> String {
format(date, "yyyy-MM-dd HH:mm", calendar: calendar)
}
private static func format(_ date: Date, _ format: String, calendar: Calendar) -> String {
let formatter = DateFormatter()
formatter.calendar = calendar
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = calendar.timeZone
formatter.dateFormat = format
return formatter.string(from: date)
}
// MARK: - Tokens
private static func isEmptyToken(_ text: String) -> Bool {
let folded = text
.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "。.!"))
.lowercased()
return emptyTokens.contains(folded)
}
private static func stripBullet(_ line: String) -> String {
var text = line.trimmingCharacters(in: .whitespacesAndNewlines)
let prefixes = ["- ", "* ", "", ""]
for prefix in prefixes where text.hasPrefix(prefix) {
text = String(text.dropFirst(prefix.count))
.trimmingCharacters(in: .whitespacesAndNewlines)
}
if let dotted = text.range(of: #"^\d+[\.\)、]\s*"#, options: .regularExpression) {
text = String(text[dotted.upperBound...])
.trimmingCharacters(in: .whitespacesAndNewlines)
}
return text
}
/// One long title that is essentially the clipboard body is not an event.
private static func isWholeClipboardEcho(_ item: String, source: String?) -> Bool {
guard let source, source.count > 80 else { return false }
let fields = item.split(separator: "|", omittingEmptySubsequences: false)
guard fields.count >= 3 else { return false }
let title = String(fields[2])
guard title.count > 80 else { return false }
let a = collapse(title)
let b = collapse(source)
guard !a.isEmpty, !b.isEmpty else { return false }
if a == b { return true }
return a.contains(b) || b.contains(a)
}
private static func collapse(_ text: String) -> String {
text.components(separatedBy: .whitespacesAndNewlines)
.filter { !$0.isEmpty }
.joined(separator: " ")
.lowercased()
}
}
@@ -0,0 +1,25 @@
// AIGenericSkillExport.swift
// OSGKeyboard · Shared
//
// Line-oriented Shortcut input for user-created export skills. Built-in
// extract-todos / extract-events keep their dedicated parsers.
import Foundation
public enum AIGenericSkillExport {
public static let maximumItems = 20
public static func items(from answer: String) -> [String] {
let trimmed = answer.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
if trimmed.compare("NONE", options: .caseInsensitive) == .orderedSame {
return []
}
let lines = trimmed
.split(whereSeparator: \.isNewline)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
if lines.isEmpty { return [trimmed] }
return Array(lines.prefix(maximumItems))
}
}
@@ -0,0 +1,126 @@
// AIMapNavigation.swift
// OSGKeyboard · Shared
//
// Builds one turn-by-turn URL. Provider order is Amap () Baidu
// Apple Maps. Shortcuts cannot call `canOpenURL`, so the host injects that
// check and passes the resulting URL to the companion Shortcut.
import Foundation
public enum AIMapProvider: String, Equatable, Sendable {
case amap
case baidu
case apple
}
public enum AIMapNavigation: Sendable {
public static let sourceApplication = "OSGKeyboard"
public static let baiduSource = "ios.osgkeyboard"
/// Amap's usual current-location label when we have no coordinates.
public static let amapCurrentLocationName = "我的位置"
public static func provider(canOpen: (URL) -> Bool) -> AIMapProvider {
if canOpen(probe("iosamap")) || canOpen(probe("amapuri")) {
return .amap
}
if canOpen(probe("baidumap")) {
return .baidu
}
return .apple
}
public static func url(for route: AIMapRoute, canOpen: (URL) -> Bool) -> URL {
switch provider(canOpen: canOpen) {
case .amap:
if canOpen(probe("iosamap")) {
return amapURL(route, useLegacyScheme: true)
}
return amapURL(route, useLegacyScheme: false)
case .baidu:
return baiduURL(route)
case .apple:
return appleURL(route)
}
}
/// Keyboard payload is `origin|destination`. Host turns it into one URL.
public static func shortcutInput(
from encoded: String,
canOpen: (URL) -> Bool
) -> String? {
guard let route = AIAddressExtraction.route(from: encoded) else { return nil }
return url(for: route, canOpen: canOpen).absoluteString
}
// MARK: - Providers
private static func amapURL(_ route: AIMapRoute, useLegacyScheme: Bool) -> URL {
let originName = route.origin ?? amapCurrentLocationName
let items = [
URLQueryItem(name: "sourceApplication", value: sourceApplication),
URLQueryItem(name: "sname", value: originName),
URLQueryItem(name: "dname", value: route.destination),
URLQueryItem(name: "dev", value: "0"),
URLQueryItem(name: "t", value: "0"),
]
if useLegacyScheme {
return makeURL(scheme: "iosamap", host: "path", path: nil, items: items)
}
let url = makeURL(scheme: "amapuri", host: "route", path: "/plan/", items: items)
// URLComponents drops the trailing slash; Amap's documented path is `/plan/`.
let withSlash = url.absoluteString.replacingOccurrences(
of: "://route/plan?",
with: "://route/plan/?"
)
return URL(string: withSlash) ?? url
}
private static func baiduURL(_ route: AIMapRoute) -> URL {
var items = [
URLQueryItem(name: "destination", value: "name:\(route.destination)"),
URLQueryItem(name: "mode", value: "driving"),
URLQueryItem(name: "src", value: baiduSource),
]
if let origin = route.origin {
items.insert(
URLQueryItem(name: "origin", value: "name:\(origin)"),
at: 0
)
}
return makeURL(scheme: "baidumap", host: "map", path: "/direction", items: items)
}
private static func appleURL(_ route: AIMapRoute) -> URL {
var items = [
URLQueryItem(name: "daddr", value: route.destination),
URLQueryItem(name: "dirflg", value: "d"),
]
if let origin = route.origin {
items.insert(URLQueryItem(name: "saddr", value: origin), at: 0)
}
// `URLComponents(string: "maps://")` keeps the `://` that opening needs.
var components = URLComponents(string: "maps://")!
components.queryItems = items
return components.url ?? URL(string: "maps://")!
}
private static func probe(_ scheme: String) -> URL {
URL(string: "\(scheme)://")!
}
private static func makeURL(
scheme: String,
host: String?,
path: String?,
items: [URLQueryItem]
) -> URL {
var components = URLComponents()
components.scheme = scheme
components.host = host
if let path {
components.path = path
}
components.queryItems = items
return components.url ?? URL(string: "\(scheme)://")!
}
}
@@ -8,14 +8,15 @@
import Foundation
public enum AIModeLLMClientFactory {
/// Build an AI-mode client. Thinking is always forced on for this path.
/// When `allowWebSearch` is false, returns the plain polish-compatible client.
/// Build an AI-mode client. Thinking defaults on for spoken questions.
/// Clipboard skills pass `thinkingEnabled` from the skill (built-in: off).
public static func make(
providerId: String,
baseURL: String,
apiKey: String,
model: String,
allowWebSearch: Bool = true,
thinkingEnabled: Bool = true,
session: URLSession = .shared
) -> any LLMClient {
let plain = LLMClientFactory.make(
@@ -23,10 +24,10 @@ public enum AIModeLLMClientFactory {
baseURL: baseURL,
apiKey: apiKey,
model: model,
thinkingEnabled: true,
thinkingEnabled: thinkingEnabled,
session: session
)
guard allowWebSearch else { return plain }
guard thinkingEnabled, allowWebSearch else { return plain }
guard let searching = makeSearchingClient(
providerId: providerId,
@@ -123,7 +123,8 @@ public struct AIQuestionService: Sendable {
public static func configured(
store: any ConfigurationStore,
conversations: AIConversationStore
conversations: AIConversationStore,
thinkingEnabled: Bool = true
) throws -> AIQuestionService {
// Same provider + baseURL + model resolution as dictation polish so the
// Settings LLM card is the single source of truth for both modes.
@@ -151,7 +152,8 @@ public struct AIQuestionService: Sendable {
baseURL: endpoint.baseURL,
apiKey: apiKey,
model: endpoint.model,
allowWebSearch: true
allowWebSearch: true,
thinkingEnabled: thinkingEnabled
),
conversations: conversations,
responseLength: store.aiResponseLength
@@ -0,0 +1,94 @@
// AIShortcutShareLink.swift
// OSGKeyboard · Shared
//
// Validates iCloud Shortcut share URLs and reads the published name from
// Apple's undocumented records endpoint. Running a Shortcut still requires
// that name (`shortcuts://run-shortcut?name=`); the share link is install-only.
import Foundation
public enum AIShortcutShareLink {
/// `https://www.icloud.com/shortcuts/{token}` or `https://icloud.com/shortcuts/{token}`.
public static func isValid(_ url: URL) -> Bool {
guard let token = AIAgentShortcutRun.iCloudShareToken(from: url) else { return false }
let allowed = CharacterSet.alphanumerics
return (16...64).contains(token.count)
&& token.unicodeScalars.allSatisfy { allowed.contains($0) }
}
public static func parse(_ raw: String) -> URL? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let withScheme: String
if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") {
withScheme = trimmed
} else {
withScheme = "https://\(trimmed)"
}
guard let url = URL(string: withScheme), isValid(url) else { return nil }
return url
}
}
public enum AIShortcutShareMetadataError: Error, Equatable, Sendable {
case invalidLink
case network
case missingName
}
public enum AIShortcutShareMetadata {
public static func recordsURL(for shareURL: URL) -> URL? {
guard let token = AIAgentShortcutRun.iCloudShareToken(from: shareURL) else { return nil }
return URL(string: "https://www.icloud.com/shortcuts/api/records/\(token)")
}
/// Parses `fields.name.value`, or the older `records[0].fields.name.value`.
public static func name(fromRecordsJSON data: Data) throws -> String {
let object = try JSONSerialization.jsonObject(with: data)
guard let root = object as? [String: Any] else {
throw AIShortcutShareMetadataError.missingName
}
if let name = stringName(in: root) {
return name
}
if let records = root["records"] as? [[String: Any]],
let first = records.first,
let name = stringName(in: first) {
return name
}
throw AIShortcutShareMetadataError.missingName
}
public static func fetchName(
from shareURL: URL,
session: URLSession = .shared
) async throws -> String {
guard let url = recordsURL(for: shareURL) else {
throw AIShortcutShareMetadataError.invalidLink
}
let data: Data
do {
let (bytes, response) = try await session.data(from: url)
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else {
throw AIShortcutShareMetadataError.network
}
data = bytes
} catch let error as AIShortcutShareMetadataError {
throw error
} catch {
throw AIShortcutShareMetadataError.network
}
return try name(fromRecordsJSON: data)
}
private static func stringName(in record: [String: Any]) -> String? {
guard let fields = record["fields"] as? [String: Any],
let nameField = fields["name"] as? [String: Any],
let value = nameField["value"] as? String else {
return nil
}
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
+43 -4
View File
@@ -99,7 +99,11 @@ public struct AppGroupStore: @unchecked Sendable {
public var localASRCustomLanguageModelEnabled: Bool { configuration.localASRCustomLanguageModelEnabled }
/// Kept off `AppGroupConfiguration.save()` so other settings writes cannot clobber it.
public var agentSkillLayout: AIAgentSkillLayout {
Self.decodeAgentSkillLayout(from: defaults)
Self.decodeAgentSkillLayout(from: defaults, userCatalog: agentUserSkillCatalog)
}
public var agentUserSkillCatalog: AIUserSkillCatalog {
Self.decodeUserSkillCatalog(from: defaults)
}
// MARK: - Writes
@@ -205,7 +209,9 @@ public struct AppGroupStore: @unchecked Sendable {
public func setAgentSkillLayout(_ layout: AIAgentSkillLayout) {
do {
let data = try JSONEncoder().encode(layout.sanitized())
let data = try JSONEncoder().encode(
layout.sanitized(catalog: AIClipboardSkillCatalog.all(userCatalog: agentUserSkillCatalog))
)
defaults.set(data, forKey: AppGroupConfiguration.Keys.agentSkillLayout)
} catch {
OSGLog.config.warning("agentSkillLayout encode failed: \(error.localizedDescription, privacy: .public)")
@@ -213,6 +219,20 @@ public struct AppGroupStore: @unchecked Sendable {
AppGroupConfigDarwin.postConfigChanged()
}
public func setAgentUserSkillCatalog(_ catalog: AIUserSkillCatalog) {
do {
defaults.set(
try JSONEncoder().encode(catalog),
forKey: AppGroupConfiguration.Keys.agentUserSkillCatalog
)
} catch {
OSGLog.config.warning(
"agentUserSkillCatalog encode failed: \(error.localizedDescription, privacy: .public)"
)
}
AppGroupConfigDarwin.postConfigChanged()
}
public func setPendingShortcutRun(skillID: String, titles: [String]) {
let payload = AIAgentShortcutRunPayload(skillID: skillID, titles: titles)
if let data = AIAgentShortcutRun.encode(payload) {
@@ -244,18 +264,37 @@ public struct AppGroupStore: @unchecked Sendable {
return payload
}
private static func decodeAgentSkillLayout(from defaults: UserDefaults) -> AIAgentSkillLayout {
private static func decodeAgentSkillLayout(
from defaults: UserDefaults,
userCatalog: AIUserSkillCatalog
) -> AIAgentSkillLayout {
let catalog = AIClipboardSkillCatalog.all(userCatalog: userCatalog)
guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentSkillLayout) else {
return .default
}
do {
return try JSONDecoder().decode(AIAgentSkillLayout.self, from: data).sanitized()
return try JSONDecoder().decode(AIAgentSkillLayout.self, from: data)
.sanitized(catalog: catalog)
} catch {
OSGLog.config.warning("agentSkillLayout decode failed: \(error.localizedDescription, privacy: .public)")
return .default
}
}
private static func decodeUserSkillCatalog(from defaults: UserDefaults) -> AIUserSkillCatalog {
guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentUserSkillCatalog) else {
return .empty
}
do {
return try JSONDecoder().decode(AIUserSkillCatalog.self, from: data)
} catch {
OSGLog.config.warning(
"agentUserSkillCatalog decode failed: \(error.localizedDescription, privacy: .public)"
)
return .empty
}
}
public var hasCompletedOnboarding: Bool {
get { configuration.hasCompletedOnboarding }
set { setHasCompletedOnboarding(newValue) }