148ff807f2
Ship a Skills catalog with drag-to-reorder chips and a companion Shortcut that receives extracted titles and writes them to Reminders.
216 lines
8.9 KiB
Swift
216 lines
8.9 KiB
Swift
// AIClipboardSkill.swift
|
|
// OSGKeyboard · Shared
|
|
//
|
|
// Built-in clipboard actions for AI idle. The catalog is an ordered list so
|
|
// 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 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 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
|
|
}
|
|
}
|
|
|
|
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"
|
|
)!
|
|
|
|
/// 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",
|
|
cardTitleKey: "skills.reply.name",
|
|
descriptionKey: "skills.reply.description",
|
|
kind: .transform,
|
|
isDefault: true
|
|
),
|
|
AIClipboardSkill(
|
|
id: summarizeID,
|
|
systemImage: "doc.text.magnifyingglass",
|
|
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",
|
|
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
|
|
),
|
|
]
|
|
|
|
/// 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] {
|
|
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(
|
|
for skill: AIClipboardSkill,
|
|
locale: String,
|
|
translationTargetLocaleId: String
|
|
) -> String {
|
|
instruction(
|
|
skillID: skill.id,
|
|
locale: locale,
|
|
translationTargetLocaleId: translationTargetLocaleId
|
|
)
|
|
}
|
|
|
|
/// Compact Translate-chip label. Unset target → 中英互译; Chinese UI
|
|
/// targeting 简/繁 → 简繁互转 (avoids「中译中」); otherwise 中译× / To XX.
|
|
public static func translateButtonTitle(
|
|
translationTargetLocaleId: String,
|
|
uiLanguage: AppUILanguage
|
|
) -> String {
|
|
let isChineseUI = uiLanguage.resolvedLanguageCode() == "zh-Hans"
|
|
if TranslationLanguageCatalog.isOff(translationTargetLocaleId) {
|
|
return isChineseUI ? "中↔英" : "CN↔EN"
|
|
}
|
|
let target = TranslationLanguageCatalog.resolve(translationTargetLocaleId)
|
|
if isChineseUI, target.isChineseScript {
|
|
return "简↔繁"
|
|
}
|
|
if isChineseUI {
|
|
return "中译\(target.chineseShort)"
|
|
}
|
|
return "To \(target.englishShort)"
|
|
}
|
|
|
|
public static func instruction(
|
|
skillID: String,
|
|
locale: String,
|
|
translationTargetLocaleId: String
|
|
) -> String {
|
|
let zh = locale == "zh"
|
|
switch skillID {
|
|
case replyID:
|
|
return zh
|
|
? "请根据剪贴板内容起草一段礼貌、简洁的回复,语气自然,可直接发送。"
|
|
: "Draft a concise, polite reply the user can send, based on the clipboard text."
|
|
case summarizeID:
|
|
return zh
|
|
? "请概括剪贴板内容的核心意思,保留关键事实与结论,不要改写成可发送的短消息。"
|
|
: "Summarize the clipboard text: keep the key facts and conclusions; do not rewrite it as a sendable short message."
|
|
case translateID:
|
|
return translateInstruction(
|
|
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
|
|
? "请根据剪贴板内容完成用户选择的操作。"
|
|
: "Complete the selected action using the clipboard text."
|
|
}
|
|
}
|
|
|
|
/// Uses the keyboard translation target when set; otherwise Chinese ↔ English.
|
|
private static func translateInstruction(
|
|
locale: String,
|
|
translationTargetLocaleId: String
|
|
) -> String {
|
|
let zh = locale == "zh"
|
|
if !TranslationLanguageCatalog.isOff(translationTargetLocaleId) {
|
|
let language = TranslationLanguageCatalog.resolve(translationTargetLocaleId)
|
|
let name = language.promptLanguageName
|
|
return zh
|
|
? "请将剪贴板内容翻译成\(name),保留原意与语气。"
|
|
: "Translate the clipboard text into \(name), preserving meaning and tone."
|
|
}
|
|
return zh
|
|
? "请将剪贴板内容在中文与英文之间互译:若原文主要是中文则译成自然英文,若主要是英文则译成自然中文。保留原意与语气。"
|
|
: "Translate the clipboard between Chinese and English: if it is primarily Chinese, produce natural English; if primarily English, produce natural Chinese. Preserve meaning and tone."
|
|
}
|
|
}
|