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
@@ -0,0 +1,128 @@
// AIAgentShortcutRun.swift
// OSGKeyboard · Shared
//
// Ephemeral App Group payload for one keyboard host Shortcuts hop.
// The keyboard writes titles here, then opens `osgkeyboard://skill/run`.
// The host consumes the payload (once) and opens the Shortcuts URL.
import Foundation
public struct AIAgentShortcutRunPayload: Codable, Equatable, Sendable {
public let skillID: String
public let titles: [String]
public let createdAt: Date
public init(skillID: String, titles: [String], createdAt: Date = Date()) {
self.skillID = skillID
self.titles = titles
self.createdAt = createdAt
}
public var joinedTitles: String {
titles.joined(separator: "\n")
}
}
public enum AIAgentShortcutRun {
public static let pendingKey = "config.aiAgentSkills.pendingRun.v1"
/// Drop payloads older than this; a leftover write must not fire later.
public static let payloadTTL: TimeInterval = 60
public static func shortcutsRunURL(
name: String,
text: String,
xSuccess: String? = nil,
xError: String? = nil,
xCancel: String? = nil
) -> URL? {
var components = URLComponents()
components.scheme = "shortcuts"
let usesCallback = xSuccess != nil || xError != nil || xCancel != nil
if usesCallback {
components.host = "x-callback-url"
components.path = "/run-shortcut"
} else {
components.host = "run-shortcut"
}
var items = [
URLQueryItem(name: "name", value: name),
URLQueryItem(name: "input", value: "text"),
URLQueryItem(name: "text", value: text),
]
if let xSuccess {
items.append(URLQueryItem(name: "x-success", value: xSuccess))
}
if let xError {
items.append(URLQueryItem(name: "x-error", value: xError))
}
if let xCancel {
items.append(URLQueryItem(name: "x-cancel", value: xCancel))
}
components.queryItems = items
return components.url
}
/// Xcode / Console search: `OSGDiag/skills`. DEBUG builds include bodies.
public static func trace(_ message: String) {
OSGDiag.log(message, category: "skills")
}
/// Single-line preview so Console keeps the format (`\\n` for newlines).
public static func preview(_ text: String, limit: Int = 1200) -> String {
let escaped = text
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\r\n", with: "\\n")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\n")
.replacingOccurrences(of: "\t", with: "\\t")
if escaped.count <= limit { return escaped }
return String(escaped.prefix(limit)) + "…(chars=\(text.count))"
}
public static func traceBody(_ label: String, _ text: String) {
#if DEBUG
trace("\(label) chars=\(text.count) body=\(preview(text))")
#else
trace("\(label) chars=\(text.count)")
#endif
}
/// Opens the Shortcuts Add sheet for an iCloud share token.
/// Do not `open` the HTTPS share page from the app Universal Links
/// often land on Gallery and drop the token.
public static func shortcutsInstallURL(from shareURL: URL) -> URL? {
guard let token = iCloudShareToken(from: shareURL) else { return nil }
return URL(string: "shortcuts://shortcuts/\(token)")
}
public static func iCloudShareToken(from shareURL: URL) -> String? {
guard let host = shareURL.host, host.contains("icloud.com") else { return nil }
let parts = shareURL.path.split(separator: "/").map(String.init)
guard let index = parts.firstIndex(of: "shortcuts"),
parts.count > index + 1 else { return nil }
let token = parts[index + 1]
guard token != "api", !token.isEmpty else { return nil }
return token
}
public static func openShortcutURL(name: String) -> URL? {
var components = URLComponents()
components.scheme = "shortcuts"
components.host = "open-shortcut"
components.queryItems = [URLQueryItem(name: "name", value: name)]
return components.url
}
public static func encode(_ payload: AIAgentShortcutRunPayload) -> Data? {
try? JSONEncoder().encode(payload)
}
public static func decode(_ data: Data, now: Date = Date()) -> AIAgentShortcutRunPayload? {
guard let payload = try? JSONDecoder().decode(AIAgentShortcutRunPayload.self, from: data) else {
return nil
}
guard now.timeIntervalSince(payload.createdAt) <= payloadTTL else { return nil }
guard !payload.titles.isEmpty else { return nil }
return payload
}
}
@@ -0,0 +1,110 @@
// AIAgentSkillLayoutStore.swift
// OSGKeyboard · Shared
//
// Observable facade over the persisted skill layout. The Skills tab mutates
// this; the keyboard reads the same App Group snapshot on each config poll.
import Foundation
import Combine
@MainActor
public final class AIAgentSkillLayoutStore: ObservableObject {
public static let shared = AIAgentSkillLayoutStore()
@Published public private(set) var layout: AIAgentSkillLayout
private let defaults: UserDefaults?
private let persist: (AIAgentSkillLayout) -> Void
private let load: () -> AIAgentSkillLayout
public init(defaults: UserDefaults? = nil) {
if let defaults {
self.defaults = defaults
self.load = { AppGroupStore(defaults: defaults).agentSkillLayout }
self.persist = { AppGroupStore(defaults: defaults).setAgentSkillLayout($0) }
} else {
self.defaults = nil
self.load = { AppGroupStore().agentSkillLayout }
self.persist = { AppGroupStore().setAgentSkillLayout($0) }
}
self.layout = self.load()
}
public func reload() {
layout = load()
}
public var enabledSkills: [AIClipboardSkill] {
AIClipboardSkillCatalog.visible(enabledIDs: layout.enabledIDs)
}
public var availableSkills: [AIClipboardSkill] {
AIClipboardSkillCatalog.catalog.filter { !layout.isEnabled($0.id) }
}
@discardableResult
public func enable(_ id: String) -> AIAgentSkillEnableResult {
let current = layout.sanitized()
guard let skill = AIClipboardSkillCatalog.skill(id: id) else { return .unknown }
if current.isEnabled(id) { return .alreadyEnabled }
if skill.requiresShortcut, !current.hasConfirmedShortcut(id) {
return .needsShortcut
}
if current.isFull { return .full }
commit(
AIAgentSkillLayout(
enabledIDs: current.enabledIDs + [id],
confirmedShortcutIDs: current.confirmedShortcutIDs
)
)
return .enabled
}
/// 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(
AIAgentSkillLayout(
enabledIDs: current.enabledIDs.filter { $0 != id },
confirmedShortcutIDs: current.confirmedShortcutIDs
)
)
}
/// 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 {
return .unknown
}
var current = layout.sanitized()
if !current.confirmedShortcutIDs.contains(id) {
current.confirmedShortcutIDs.append(id)
}
commit(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 }
ids.move(
fromOffsets: IndexSet(integer: from),
toOffset: to > from ? to + 1 : to
)
commit(
AIAgentSkillLayout(
enabledIDs: ids,
confirmedShortcutIDs: layout.confirmedShortcutIDs
)
)
}
private func commit(_ layout: AIAgentSkillLayout) {
persist(layout)
self.layout = load()
}
}
@@ -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
? "请根据剪贴板内容完成用户选择的操作。"
@@ -0,0 +1,82 @@
// AITodoExtraction.swift
// OSGKeyboard · Shared
//
// Parses the LLM's extract-todos reply into reminder titles. Fail closed:
// empty / NONE / "no tasks" never become a Shortcut run. A single long
// echo of the clipboard is also rejected so the model cannot dump the
// whole paste as one reminder.
import Foundation
public enum AITodoExtraction: Sendable {
public static let maximumItems = 20
private static let emptyTokens: Set<String> = [
"none", "no", "n/a", "na", "nil", "null",
"", "没有", "没有待办", "没有待办事项", "无待办", "无待办事项",
"no tasks", "no task", "no todos", "no to-dos", "no to-do",
"no actionable items", "no action items",
]
/// Titles to send to the companion Shortcut. Empty do not run it.
public static func items(from raw: String, sourceClipboard: String? = nil) -> [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) {
let title = stripBullet(line)
guard !title.isEmpty, !isEmptyToken(title) else { continue }
let key = title.lowercased()
guard seen.insert(key).inserted else { continue }
items.append(title)
if items.count == maximumItems { break }
}
if items.count == 1, isWholeClipboardEcho(items[0], source: sourceClipboard) {
return []
}
return items
}
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 line that is essentially the clipboard body is not a todo.
private static func isWholeClipboardEcho(_ item: String, source: String?) -> Bool {
guard let source, source.count > 80, item.count > 80 else { return false }
let a = collapse(item)
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()
}
}
@@ -97,6 +97,10 @@ public struct AppGroupStore: @unchecked Sendable {
public var polishProviderIdOverride: String? { configuration.polishProviderIdOverride }
public var isCloudAPIKeyMissingForVoiceInput: Bool { configuration.isCloudAPIKeyMissingForVoiceInput }
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)
}
// MARK: - Writes
@@ -199,6 +203,59 @@ public struct AppGroupStore: @unchecked Sendable {
mutateConfiguration { $0.localASRCustomLanguageModelEnabled = enabled }
}
public func setAgentSkillLayout(_ layout: AIAgentSkillLayout) {
do {
let data = try JSONEncoder().encode(layout.sanitized())
defaults.set(data, forKey: AppGroupConfiguration.Keys.agentSkillLayout)
} catch {
OSGLog.config.warning("agentSkillLayout 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) {
defaults.set(data, forKey: AIAgentShortcutRun.pendingKey)
AIAgentShortcutRun.trace(
"appGroup.writePending skill=\(skillID) items=\(titles.count) bytes=\(data.count)"
)
} else {
AIAgentShortcutRun.trace("appGroup.writePending FAILED encode skill=\(skillID)")
}
}
public func consumePendingShortcutRun(now: Date = Date()) -> AIAgentShortcutRunPayload? {
let data = defaults.data(forKey: AIAgentShortcutRun.pendingKey)
defaults.removeObject(forKey: AIAgentShortcutRun.pendingKey)
guard let data else {
AIAgentShortcutRun.trace("appGroup.consumePending missing")
return nil
}
guard let payload = AIAgentShortcutRun.decode(data, now: now) else {
AIAgentShortcutRun.trace(
"appGroup.consumePending dropped bytes=\(data.count) (expired or empty titles)"
)
return nil
}
AIAgentShortcutRun.trace(
"appGroup.consumePending ok skill=\(payload.skillID) items=\(payload.titles.count)"
)
return payload
}
private static func decodeAgentSkillLayout(from defaults: UserDefaults) -> AIAgentSkillLayout {
guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentSkillLayout) else {
return .default
}
do {
return try JSONDecoder().decode(AIAgentSkillLayout.self, from: data).sanitized()
} catch {
OSGLog.config.warning("agentSkillLayout decode failed: \(error.localizedDescription, privacy: .public)")
return .default
}
}
public var hasCompletedOnboarding: Bool {
get { configuration.hasCompletedOnboarding }
set { setHasCompletedOnboarding(newValue) }
@@ -140,6 +140,12 @@ public final class KeyboardState: ObservableObject {
@Published public var clipboardHistoryEnabled: Bool = false
/// Opt-in clipboard suggestion strip (requires history enabled).
@Published public var clipboardCandidateBarEnabled: Bool = false
/// Skills-tab order for clipboard chips (max 8). Empty hint carousel.
@Published public var enabledClipboardSkillIDs: [String] = AIAgentSkillLayout.defaultEnabledIDs
/// Export skill currently waiting on the LLM. Nil for transform skills.
@Published public var pendingClipboardSkillID: String?
/// In-keyboard toast (e.g. no todos). Does not leave the host app.
@Published public var skillTipText: String?
/// Host field is a password / secure entry never read pasteboard.
@Published public var isSecureTextEntry: Bool = false
/// Secure fields hide every clipboard-history entry point.
@@ -239,8 +245,10 @@ public final class KeyboardState: ObservableObject {
public var sendAIAnswer: () -> Void = {}
/// Sends a tapped idle hint card as the AI question (skip microphone).
public var submitAIHint: (AIHintCard) -> Void = { _ in }
/// Sends a clipboard skill (reply / summarize / translate / future).
/// Sends a clipboard skill (reply / summarize / translate / export).
public var submitAIClipboardSkill: (AIClipboardSkill) -> Void = { _ in }
/// Writes extract-todos titles and opens the host to run the Shortcut.
public var runClipboardExportSkill: (String, [String]) -> Void = { _, _ in }
public var openSettings: () -> Void = {}
/// Opens the host app straight to input-resource deployment. Used by the
/// typing surface when Rime resources have not been deployed yet.