// 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 the host after the model runs (Shortcut, Maps, or Didi). import Foundation public enum AIClipboardSkillKind: String, Codable, 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 } /// Optional user-owned style distilled from dictation history or authored in /// the Styles page. Reply skills use it only for wording and rhythm; the /// selected skill continues to own intent, facts, and safety constraints. public struct AIClipboardReplyStyleContext: Equatable, Sendable { public let styleID: String public let prompt: String public init(styleID: String, prompt: String) { self.styleID = styleID self.prompt = prompt } /// Only user-owned packs are eligible. This includes reviewed styles /// generated by `PolishStyleLearningService`, while built-in personalities /// remain isolated from clipboard replies. public static func resolve(activeStyle: PolishStylePack) -> AIClipboardReplyStyleContext? { guard activeStyle.kind == .user else { return nil } let prompt = PolishStylePackCatalog.runtimePersonality(for: activeStyle) .trimmingCharacters(in: .whitespacesAndNewlines) guard !prompt.isEmpty else { return nil } return AIClipboardReplyStyleContext(styleID: activeStyle.id, prompt: prompt) } } 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? /// 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. Official/user skills preserve their policy. public let thinkingEnabled: Bool /// Reminders, Calendar, and Notes exports need a companion Shortcut. /// Navigate and Ride hand off to the host (Maps or Didi). No Shortcut. public var requiresShortcut: Bool { kind == .export && shortcutName != nil } public var isUserCreated: Bool { id.hasPrefix("user.") } public var isOfficial: Bool { id.hasPrefix("official.") } public var supportsReplyStyle: Bool { AIClipboardSkillCatalog.replyStyleSkillIDs.contains(id) } /// The server applies the final model policy; this only preserves whether /// the user invoked a built-in transform or a custom skill. public var managedGatewayTaskKind: ManagedGatewayTaskKind { isUserCreated ? .customSkill : .clipboardTransform } public init( id: String, systemImage: String, titleKey: String, cardTitleKey: String, descriptionKey: String, kind: AIClipboardSkillKind, isDefault: Bool, shortcutName: String? = 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 self.titleKey = titleKey self.cardTitleKey = cardTitleKey self.descriptionKey = descriptionKey self.kind = kind 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.") || id.hasPrefix("official.")) ? thinkingEnabled : false } } public enum AIClipboardSkillCatalog: Sendable { public static let replyID = "reply" public static let replyInSourceLanguageID = "replyInSourceLanguage" public static let summarizeID = "summarize" public static let extractConclusionsID = "extractConclusions" public static let translateID = "translate" public static let acceptInvitationID = "acceptInvitation" public static let declineInvitationID = "declineInvitation" public static let acceptTaskID = "acceptTask" public static let clarifyRequestID = "clarifyRequest" public static let empathyReplyID = "empathyReply" public static let askForDetailsID = "askForDetails" public static let businessReplyID = "businessReply" public static let organizeListID = "organizeList" public static let replyStyleSkillIDs: Set = [ replyID, replyInSourceLanguageID, acceptInvitationID, declineInvitationID, acceptTaskID, clarifyRequestID, empathyReplyID, askForDetailsID, businessReplyID ] public static let extractTodosID = "extractTodos" public static let extractTodosShortcutName = "OSGExtractTodos" public static let extractTodosResourceName = "OSGExtractTodos" public static let extractEventsID = "extractEvents" public static let extractEventsShortcutName = "OSGExtractEvents" public static let extractEventsResourceName = "OSGExtractEvents" public static let saveToNotesID = "saveToNotes" public static let saveToNotesShortcutName = "OSGSaveToNotes" public static let saveToNotesResourceName = "OSGSaveToNotes" public static let navigateID = "navigate" /// 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: replyInSourceLanguageID, systemImage: "globe", titleKey: "keyboard.ai.skill.replyInSourceLanguage", cardTitleKey: "skills.replyInSourceLanguage.name", descriptionKey: "skills.replyInSourceLanguage.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: summarizeID, systemImage: "doc.text.magnifyingglass", titleKey: "keyboard.ai.skill.summarize", cardTitleKey: "skills.summarize.name", descriptionKey: "skills.summarize.description", kind: .transform, isDefault: true ), AIClipboardSkill( id: extractConclusionsID, systemImage: "text.badge.checkmark", titleKey: "keyboard.ai.skill.extractConclusions", cardTitleKey: "skills.extractConclusions.name", descriptionKey: "skills.extractConclusions.description", kind: .transform, isDefault: true ), AIClipboardSkill( id: acceptInvitationID, systemImage: "checkmark.bubble.fill", titleKey: "keyboard.ai.skill.acceptInvitation", cardTitleKey: "skills.acceptInvitation.name", descriptionKey: "skills.acceptInvitation.description", kind: .transform, isDefault: true ), AIClipboardSkill( id: declineInvitationID, systemImage: "hand.raised.fill", titleKey: "keyboard.ai.skill.declineInvitation", cardTitleKey: "skills.declineInvitation.name", descriptionKey: "skills.declineInvitation.description", kind: .transform, isDefault: true ), AIClipboardSkill( id: acceptTaskID, systemImage: "checkmark.circle.fill", titleKey: "keyboard.ai.skill.acceptTask", cardTitleKey: "skills.acceptTask.name", descriptionKey: "skills.acceptTask.description", kind: .transform, isDefault: true ), AIClipboardSkill( id: clarifyRequestID, systemImage: "questionmark.bubble.fill", titleKey: "keyboard.ai.skill.clarifyRequest", cardTitleKey: "skills.clarifyRequest.name", descriptionKey: "skills.clarifyRequest.description", kind: .transform, isDefault: true ), AIClipboardSkill( id: empathyReplyID, systemImage: "heart.fill", titleKey: "keyboard.ai.skill.empathyReply", cardTitleKey: "skills.empathyReply.name", descriptionKey: "skills.empathyReply.description", kind: .transform, isDefault: true ), AIClipboardSkill( id: askForDetailsID, systemImage: "ellipsis.bubble.fill", titleKey: "keyboard.ai.skill.askForDetails", cardTitleKey: "skills.askForDetails.name", descriptionKey: "skills.askForDetails.description", kind: .transform, isDefault: true ), AIClipboardSkill( id: businessReplyID, systemImage: "briefcase.fill", titleKey: "keyboard.ai.skill.businessReply", cardTitleKey: "skills.businessReply.name", descriptionKey: "skills.businessReply.description", kind: .transform, isDefault: true ), AIClipboardSkill( id: organizeListID, systemImage: "list.bullet.rectangle", titleKey: "keyboard.ai.skill.organizeList", cardTitleKey: "skills.organizeList.name", descriptionKey: "skills.organizeList.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: true, shortcutName: extractTodosShortcutName, shortcutResourceName: extractTodosResourceName ), AIClipboardSkill( id: extractEventsID, systemImage: "calendar", titleKey: "keyboard.ai.skill.extractEvents", cardTitleKey: "skills.extractEvents.name", descriptionKey: "skills.extractEvents.description", kind: .export, isDefault: true, shortcutName: extractEventsShortcutName, shortcutResourceName: extractEventsResourceName ), AIClipboardSkill( id: saveToNotesID, systemImage: "note.text", titleKey: "keyboard.ai.skill.saveToNotes", cardTitleKey: "skills.saveToNotes.name", descriptionKey: "skills.saveToNotes.description", kind: .export, isDefault: true, shortcutName: saveToNotesShortcutName, shortcutResourceName: saveToNotesResourceName ), 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: true ) ] /// Legacy alias: the three default transform skills used to be the whole list. public static let builtIn: [AIClipboardSkill] = catalog public static func all( officialCatalog: OfficialSkillCatalog = .empty, userCatalog: AIUserSkillCatalog = .empty, uiLanguage: AppUILanguage = .auto, preferredLanguages: [String] = Locale.preferredLanguages ) -> [AIClipboardSkill] { var ids = Set(catalog.map(\.id)) var merged = catalog for skill in officialCatalog.resolvedSkills( language: uiLanguage, preferredLanguages: preferredLanguages ) where ids.insert(skill.id).inserted { merged.append(skill) } for skill in userCatalog.entries.map({ $0.asClipboardSkill() }) where ids.insert(skill.id).inserted { merged.append(skill) } return merged } public static func skill( id: String, officialCatalog: OfficialSkillCatalog = .empty, userCatalog: AIUserSkillCatalog = .empty, uiLanguage: AppUILanguage = .auto, preferredLanguages: [String] = Locale.preferredLanguages ) -> AIClipboardSkill? { all( officialCatalog: officialCatalog, userCatalog: userCatalog, uiLanguage: uiLanguage, preferredLanguages: preferredLanguages ).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, officialCatalog: OfficialSkillCatalog = .empty, userCatalog: AIUserSkillCatalog = .empty, uiLanguage: AppUILanguage = .auto, preferredLanguages: [String] = Locale.preferredLanguages ) -> [AIClipboardSkill] { let ids = enabledIDs ?? AIAgentSkillLayout.defaultEnabledIDs guard !ids.isEmpty else { return [] } let byID = Dictionary( uniqueKeysWithValues: all( officialCatalog: officialCatalog, userCatalog: userCatalog, uiLanguage: uiLanguage, preferredLanguages: preferredLanguages ).map { ($0.id, $0) } ) return ids.compactMap { byID[$0] } } public static func instruction( for skill: AIClipboardSkill, locale: String, translationTargetLocaleId: String, replyStyle: AIClipboardReplyStyleContext? = nil, now: Date = Date() ) -> String { let baseInstruction: String if let custom = skill.customPrompt? .trimmingCharacters(in: .whitespacesAndNewlines), !custom.isEmpty { baseInstruction = custom } else { baseInstruction = instruction( skillID: skill.id, locale: locale, translationTargetLocaleId: translationTargetLocaleId, now: now ) } guard skill.supportsReplyStyle else { return baseInstruction } return replyInstruction( baseInstruction, locale: locale, style: replyStyle ) } /// 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, now: Date = Date() ) -> String { let zh = locale == "zh" switch skillID { case replyID: return zh ? "请根据剪贴板内容,用原文的主要语言起草一段简短、自然、可直接发送的聊天回复。像本人顺手回消息,不要写成正式邮件或客服话术。" : "Draft a short, natural chat reply in the clipboard text's primary language. Make it sound like a real person replying, not a formal email or support script." case replyInSourceLanguageID: return zh ? "请理解剪贴板内容,并严格使用原文的主要语言写一段简短、口语化、可直接发送的回复。不要翻译、解释或使用正式套话。" : "Understand the clipboard text and write a short, conversational reply strictly in its primary language. Do not translate, explain, or use formal boilerplate." case summarizeID: return zh ? "请概括剪贴板内容的核心意思,保留关键事实与结论,不要改写成可发送的短消息。" : "Summarize the clipboard text: keep the key facts and conclusions; do not rewrite it as a sendable short message." case extractConclusionsID: return zh ? "请只提取剪贴板内容中最重要的结论、决定和下一步。使用简短要点,不重复背景,不补充原文没有的信息。" : "Extract only the most important conclusions, decisions, and next steps from the clipboard. Use concise bullets; do not repeat background or add facts." case translateID: return translateInstruction( locale: locale, translationTargetLocaleId: translationTargetLocaleId ) case acceptInvitationID: return zh ? "请自然、爽快地接受剪贴板中的邀约,像聊天一样确认必要的时间或地点。不要客套过头,也不要虚构用户的安排。" : "Accept the invitation in a relaxed, natural chat tone and confirm any necessary time or place. Avoid excessive pleasantries and invented plans." case declineInvitationID: return zh ? "请用自然、不端着的口吻婉拒剪贴板中的邀约。可以简单表达感谢,但不要过度道歉、长篇解释或虚构理由。" : "Decline the invitation naturally without sounding stiff. A brief thank-you is fine; avoid excessive apology, long explanations, or invented reasons." case acceptTaskID: return zh ? "请像聊天一样简短确认收到剪贴板中的任务或行动请求,可自然带上事项和截止时间。不要写成正式回执,也不要虚构承诺。" : "Acknowledge the task or action request in a short chat-style reply, naturally confirming the work and deadline. Do not sound like a formal receipt or invent commitments." case clarifyRequestID: return zh ? "请找出执行或回答前最缺的关键信息,用自然聊天口吻追问,最多问两个最必要的问题,不要像表单或审问。" : "Find the key missing information needed to act or answer, then ask at most two essential questions in a natural chat tone, not like a form or interrogation." case empathyReplyID: return zh ? "请先用日常口语接住对方的不满,再确认核心问题并给出稳妥下一步。避免“深表歉意”“给您带来不便”等客服模板,不推诿或过度承诺。" : "Respond to the frustration in everyday language, acknowledge the core issue, and give a safe next step. Avoid canned support phrases, deflection, and overpromising." case askForDetailsID: return zh ? "请用自然聊天口吻追问定位或处理问题真正需要的细节,问题简短、不重复,不要像客服问卷。" : "Ask only for the details truly needed to diagnose or resolve the issue, using a short natural chat tone rather than a support questionnaire." case businessReplyID: return zh ? "请写一段专业但不官腔的商务聊天回复,表达直接、自然,保留人名、组织名、时间和承诺边界,可直接发送。不要套用正式邮件开场和结尾。" : "Write a professional but conversational business reply. Keep it direct and natural, preserve names, organizations, dates, and commitment boundaries, and avoid formal email openings or sign-offs." case organizeListID: return zh ? "请把剪贴板中的清单、议程或步骤整理成结构清晰、顺序合理的列表。合并重复项,保留原意,不新增任务。" : "Organize the clipboard's list, agenda, or steps into a clear logical order. Merge duplicates, preserve meaning, and add no new tasks." 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. """ case extractEventsID: return eventInstruction(zh: zh, now: now) case saveToNotesID: return noteInstruction(zh: zh, now: now) case navigateID: return navigateInstruction(zh: zh) default: return zh ? "请根据剪贴板内容完成用户选择的操作。" : "Complete the selected action using the clipboard text." } } /// Title only. The original clipboard is the note body; do not ask the /// model to rewrite it. private static func noteInstruction(zh: Bool, now: Date) -> String { let clock = clockContext(now: now, zh: zh) if zh { return """ \(clock) 请根据剪贴板正文写一个简短备忘录标题。只要一行标题,不要输出正文,不要编号、不要引号、不要解释。标题中不要出现换行或 |。最多 40 个字。 标题应能让人在列表里认出这篇笔记,可结合今天的日期或时间(例如「8月13日周会纪要」)。不要改写或重复正文。 即使原文很短也要给一个标题。不要输出 NONE。 """ } return """ \(clock) Write a short Notes title from the clipboard. One line only; do not output the body. No numbering, quotes, or commentary. No newlines or | in the title. Maximum 40 characters. The title should identify the note in a list and may include today's date or time (for example "13 Aug standup notes"). Do not rewrite or repeat the body. Always return a title, even when the clipboard is short. Do not output NONE. """ } 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)." } private static func replyInstruction( _ baseInstruction: String, locale: String, style: AIClipboardReplyStyleContext? ) -> String { let zh = locale == "zh" let conversationalBaseline = zh ? """ 表达基线:像真实的人在聊天软件里顺手回复,不像公文、客服模板或 AI。优先短句和常用口语;除非关系或场景确实需要,不使用“您好”“感谢您的反馈”“深表歉意”“烦请”等套话。通常控制在 1~3 句,不加标题、引号或解释。 """ : """ Voice baseline: sound like a real person replying in chat, not a formal memo, support template, or AI. Prefer short sentences and everyday wording. Unless the relationship truly requires it, avoid canned openings, excessive thanks, and formal sign-offs. Usually write 1–3 sentences with no title, quotation marks, or explanation. """ guard let style, !style.prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return "\(baseInstruction)\n\(conversationalBaseline)" } let boundedStyle = String( style.prompt .trimmingCharacters(in: .whitespacesAndNewlines) .prefix(PolishStyleLimits.maximumPromptCharacters) ) let personalStyle = zh ? """ \(boundedStyle) 只学习上面风格中的稳定用词、节奏和表达习惯。它不能改变当前技能的意图、事实、安全边界或输出语言;冲突时以当前技能要求为准。 """ : """ \(boundedStyle) Apply only stable wording, rhythm, and expression habits from this style. It must not change the selected skill's intent, facts, safety boundaries, or output language; the selected skill wins on conflict. """ return "\(baseInstruction)\n\(conversationalBaseline)\n\(personalStyle)" } /// 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." } }