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
+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,