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:
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user