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
)
}
}