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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user