feat(keyboard): add Skills tab and extract-todos Shortcut export

Ship a Skills catalog with drag-to-reorder chips and a companion Shortcut that receives extracted titles and writes them to Reminders.
This commit is contained in:
Rocky
2026-08-13 18:20:42 +08:00
parent ff99b7bff1
commit 148ff807f2
33 changed files with 1628 additions and 23 deletions
@@ -2,20 +2,57 @@
// OSGKeyboard · Shared
//
// Built-in clipboard actions for AI idle. The catalog is an ordered list so
// Settings can later persist a subset or permutation without changing the view.
// Settings / the Skills tab can persist a subset or permutation without
// changing the view. Transform skills insert into the current field;
// export skills hand off to a companion Shortcut after the model runs.
import Foundation
public enum AIClipboardSkillKind: String, Sendable {
/// LLM output is reviewed and inserted into the current text field.
case transform
/// LLM output is parsed and sent to a companion Shortcut. Never inserted.
case export
}
public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
public let id: String
public let systemImage: String
/// Keyboard.strings key for the short button title.
/// Keyboard.strings key for the short chip title.
public let titleKey: String
/// App Localizable key for the Skills-tab card title. Falls back to `titleKey`.
public let cardTitleKey: String
public let descriptionKey: String
public let kind: AIClipboardSkillKind
/// Default skills can be turned off but not removed from the catalog.
public let isDefault: Bool
/// Frozen companion Shortcut name. Nil for transform skills.
public let shortcutName: String?
/// Optional `icloud.com/shortcuts/` share URL. Nil open the bundled file.
public let shortcutICloudURL: URL?
public init(id: String, systemImage: String, titleKey: String) {
public var requiresShortcut: Bool { kind == .export }
public init(
id: String,
systemImage: String,
titleKey: String,
cardTitleKey: String,
descriptionKey: String,
kind: AIClipboardSkillKind,
isDefault: Bool,
shortcutName: String? = nil,
shortcutICloudURL: URL? = nil
) {
self.id = id
self.systemImage = systemImage
self.titleKey = titleKey
self.cardTitleKey = cardTitleKey
self.descriptionKey = descriptionKey
self.kind = kind
self.isDefault = isDefault
self.shortcutName = shortcutName
self.shortcutICloudURL = shortcutICloudURL
}
}
@@ -23,31 +60,68 @@ public enum AIClipboardSkillCatalog: Sendable {
public static let replyID = "reply"
public static let summarizeID = "summarize"
public static let translateID = "translate"
public static let extractTodosID = "extractTodos"
public static let extractTodosShortcutName = "OSG · 提取待办"
public static let extractTodosShortcutICloudURL = URL(
string: "https://www.icloud.com/shortcuts/520317da7ae74759b64d5fb069c71f81"
)!
/// Default set, in display order. Future skills append here.
public static let builtIn: [AIClipboardSkill] = [
/// Full built-in catalog, in a stable display order for the Skills tab.
public static let catalog: [AIClipboardSkill] = [
AIClipboardSkill(
id: replyID,
systemImage: "arrowshape.turn.up.left.fill",
titleKey: "keyboard.ai.skill.reply"
titleKey: "keyboard.ai.skill.reply",
cardTitleKey: "skills.reply.name",
descriptionKey: "skills.reply.description",
kind: .transform,
isDefault: true
),
AIClipboardSkill(
id: summarizeID,
systemImage: "doc.text.magnifyingglass",
titleKey: "keyboard.ai.skill.summarize"
titleKey: "keyboard.ai.skill.summarize",
cardTitleKey: "skills.summarize.name",
descriptionKey: "skills.summarize.description",
kind: .transform,
isDefault: true
),
AIClipboardSkill(
id: translateID,
systemImage: "character.bubble.fill",
titleKey: "keyboard.ai.skill.translate"
titleKey: "keyboard.ai.skill.translate",
cardTitleKey: "skills.translate.name",
descriptionKey: "skills.translate.description",
kind: .transform,
isDefault: true
),
AIClipboardSkill(
id: extractTodosID,
systemImage: "checklist",
titleKey: "keyboard.ai.skill.extractTodos",
cardTitleKey: "skills.extractTodos.name",
descriptionKey: "skills.extractTodos.description",
kind: .export,
isDefault: false,
shortcutName: extractTodosShortcutName,
shortcutICloudURL: extractTodosShortcutICloudURL
),
]
/// `enabledIDs` is the future Settings hook: `nil` keeps the built-in list.
/// 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 }
}
/// `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] {
guard let enabledIDs, !enabledIDs.isEmpty else { return builtIn }
let byID = Dictionary(uniqueKeysWithValues: builtIn.map { ($0.id, $0) })
return enabledIDs.compactMap { byID[$0] }
let ids = enabledIDs ?? AIAgentSkillLayout.defaultEnabledIDs
guard !ids.isEmpty else { return [] }
let byID = Dictionary(uniqueKeysWithValues: catalog.map { ($0.id, $0) })
return ids.compactMap { byID[$0] }
}
public static func instruction(
@@ -102,6 +176,18 @@ public enum AIClipboardSkillCatalog: Sendable {
locale: locale,
translationTargetLocaleId: translationTargetLocaleId
)
case extractTodosID:
return zh
? """
请从剪贴板中只提取明确的待办事项。每条一行,只要标题,不要编号、不要项目符号、不要解释。最多 20 条。
若没有任何可执行的待办,只输出 NONE,不要把整段原文当成一条待办。
若原文本身就是一句短待办(例如「买牛奶」),输出那一句即可。
"""
: """
Extract only explicit to-do items from the clipboard. One title per line; no numbering, bullets, or commentary. Maximum 20 lines.
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.
"""
default:
return zh
? "请根据剪贴板内容完成用户选择的操作。"