feat(keyboard): add custom skills plus events and navigate Shortcuts
Ship user-defined Shortcut skills, companion Events/Navigate recipes, shared skill/style card chrome, and bump the build to 69.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
// AIAddressExtraction.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Parses the LLM's navigate-skill reply into one origin|destination line.
|
||||
// Fail closed: empty / NONE / no destination never become a run. Only the
|
||||
// first valid line is kept — opening several map apps is not useful.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct AIMapRoute: Equatable, Sendable {
|
||||
/// Nil means "current location" in the map app.
|
||||
public let origin: String?
|
||||
public let destination: String
|
||||
|
||||
public init(origin: String?, destination: String) {
|
||||
self.origin = origin
|
||||
self.destination = destination
|
||||
}
|
||||
}
|
||||
|
||||
public enum AIAddressExtraction: Sendable {
|
||||
/// Canonical line: origin|destination. Empty origin keeps the pipe.
|
||||
public static let fieldSeparator: Character = "|"
|
||||
|
||||
private static let emptyTokens: Set<String> = [
|
||||
"none", "no", "n/a", "na", "nil", "null",
|
||||
"无", "没有", "没有地址", "没有地点", "无地址", "无地点",
|
||||
"没有可导航的地点", "没有可导航的地址",
|
||||
"no address", "no addresses", "no location", "no locations",
|
||||
"no place", "no places", "no destination",
|
||||
]
|
||||
|
||||
/// Lines to send to the host. Empty → do not run the companion Shortcut.
|
||||
public static func lines(
|
||||
from raw: String,
|
||||
sourceClipboard: String? = nil
|
||||
) -> [String] {
|
||||
guard let route = route(from: raw, sourceClipboard: sourceClipboard) else {
|
||||
return []
|
||||
}
|
||||
return [encode(route)]
|
||||
}
|
||||
|
||||
public static func route(
|
||||
from raw: String,
|
||||
sourceClipboard: String? = nil
|
||||
) -> AIMapRoute? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if isEmptyToken(trimmed) { return nil }
|
||||
|
||||
for line in trimmed.components(separatedBy: .newlines) {
|
||||
guard let route = parseLine(line) else { continue }
|
||||
if isWholeClipboardEcho(route, source: sourceClipboard) {
|
||||
return nil
|
||||
}
|
||||
return route
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func encode(_ route: AIMapRoute) -> String {
|
||||
"\(route.origin ?? "")|\(route.destination)"
|
||||
}
|
||||
|
||||
// MARK: - Line
|
||||
|
||||
private static func parseLine(_ line: String) -> AIMapRoute? {
|
||||
let stripped = stripBullet(line)
|
||||
guard !stripped.isEmpty, !isEmptyToken(stripped) else { return nil }
|
||||
|
||||
let parts = stripped
|
||||
.split(separator: fieldSeparator, omittingEmptySubsequences: false)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
|
||||
let originRaw: String
|
||||
let destinationRaw: String
|
||||
if parts.count == 1 {
|
||||
originRaw = ""
|
||||
destinationRaw = parts[0]
|
||||
} else {
|
||||
originRaw = parts[0]
|
||||
var destParts = Array(parts[1...])
|
||||
while destParts.last?.isEmpty == true {
|
||||
destParts.removeLast()
|
||||
}
|
||||
destinationRaw = destParts
|
||||
.joined(separator: String(fieldSeparator))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
guard let destination = sanitizedPlace(destinationRaw) else { return nil }
|
||||
let origin = sanitizedPlace(originRaw)
|
||||
if let origin, origin.caseInsensitiveCompare(destination) == .orderedSame {
|
||||
return AIMapRoute(origin: nil, destination: destination)
|
||||
}
|
||||
return AIMapRoute(origin: origin, destination: destination)
|
||||
}
|
||||
|
||||
/// Drop URLs and empty / NONE tokens. Place names stay as-is.
|
||||
private static func sanitizedPlace(_ raw: String) -> String? {
|
||||
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty, !isEmptyToken(text) else { return nil }
|
||||
if text.contains("://") { return nil }
|
||||
return text
|
||||
}
|
||||
|
||||
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 destination that is essentially the clipboard body is not a place.
|
||||
private static func isWholeClipboardEcho(_ route: AIMapRoute, source: String?) -> Bool {
|
||||
guard let source, source.count > 80, route.destination.count > 80 else {
|
||||
return false
|
||||
}
|
||||
let a = collapse(route.destination)
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -12,46 +12,65 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
|
||||
public static let shared = AIAgentSkillLayoutStore()
|
||||
|
||||
@Published public private(set) var layout: AIAgentSkillLayout
|
||||
@Published public private(set) var userCatalog: AIUserSkillCatalog
|
||||
|
||||
private let defaults: UserDefaults?
|
||||
private let persist: (AIAgentSkillLayout) -> Void
|
||||
private let load: () -> AIAgentSkillLayout
|
||||
private let persistLayout: (AIAgentSkillLayout) -> Void
|
||||
private let persistUserCatalog: (AIUserSkillCatalog) -> Void
|
||||
private let loadLayout: () -> AIAgentSkillLayout
|
||||
private let loadUserCatalog: () -> AIUserSkillCatalog
|
||||
|
||||
public init(defaults: UserDefaults? = nil) {
|
||||
if let defaults {
|
||||
self.defaults = defaults
|
||||
self.load = { AppGroupStore(defaults: defaults).agentSkillLayout }
|
||||
self.persist = { AppGroupStore(defaults: defaults).setAgentSkillLayout($0) }
|
||||
self.loadUserCatalog = { AppGroupStore(defaults: defaults).agentUserSkillCatalog }
|
||||
self.persistUserCatalog = { AppGroupStore(defaults: defaults).setAgentUserSkillCatalog($0) }
|
||||
self.loadLayout = { AppGroupStore(defaults: defaults).agentSkillLayout }
|
||||
self.persistLayout = { AppGroupStore(defaults: defaults).setAgentSkillLayout($0) }
|
||||
} else {
|
||||
self.defaults = nil
|
||||
self.load = { AppGroupStore().agentSkillLayout }
|
||||
self.persist = { AppGroupStore().setAgentSkillLayout($0) }
|
||||
self.loadUserCatalog = { AppGroupStore().agentUserSkillCatalog }
|
||||
self.persistUserCatalog = { AppGroupStore().setAgentUserSkillCatalog($0) }
|
||||
self.loadLayout = { AppGroupStore().agentSkillLayout }
|
||||
self.persistLayout = { AppGroupStore().setAgentSkillLayout($0) }
|
||||
}
|
||||
self.layout = self.load()
|
||||
self.userCatalog = self.loadUserCatalog()
|
||||
self.layout = self.loadLayout()
|
||||
}
|
||||
|
||||
public func reload() {
|
||||
layout = load()
|
||||
userCatalog = loadUserCatalog()
|
||||
layout = loadLayout()
|
||||
}
|
||||
|
||||
public var mergedCatalog: [AIClipboardSkill] {
|
||||
AIClipboardSkillCatalog.all(userCatalog: userCatalog)
|
||||
}
|
||||
|
||||
public var enabledSkills: [AIClipboardSkill] {
|
||||
AIClipboardSkillCatalog.visible(enabledIDs: layout.enabledIDs)
|
||||
AIClipboardSkillCatalog.visible(
|
||||
enabledIDs: layout.enabledIDs,
|
||||
userCatalog: userCatalog
|
||||
)
|
||||
}
|
||||
|
||||
public var availableSkills: [AIClipboardSkill] {
|
||||
AIClipboardSkillCatalog.catalog.filter { !layout.isEnabled($0.id) }
|
||||
mergedCatalog.filter { !layout.isEnabled($0.id) }
|
||||
}
|
||||
|
||||
public func userSkill(id: String) -> AIUserSkill? {
|
||||
userCatalog.skill(id: id)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func enable(_ id: String) -> AIAgentSkillEnableResult {
|
||||
let current = layout.sanitized()
|
||||
guard let skill = AIClipboardSkillCatalog.skill(id: id) else { return .unknown }
|
||||
let current = layout.sanitized(catalog: mergedCatalog)
|
||||
guard let skill = AIClipboardSkillCatalog.skill(id: id, userCatalog: userCatalog) else {
|
||||
return .unknown
|
||||
}
|
||||
if current.isEnabled(id) { return .alreadyEnabled }
|
||||
if skill.requiresShortcut, !current.hasConfirmedShortcut(id) {
|
||||
return .needsShortcut
|
||||
}
|
||||
if current.isFull { return .full }
|
||||
commit(
|
||||
commitLayout(
|
||||
AIAgentSkillLayout(
|
||||
enabledIDs: current.enabledIDs + [id],
|
||||
confirmedShortcutIDs: current.confirmedShortcutIDs
|
||||
@@ -63,8 +82,8 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
|
||||
/// 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(
|
||||
let current = layout.sanitized(catalog: mergedCatalog)
|
||||
commitLayout(
|
||||
AIAgentSkillLayout(
|
||||
enabledIDs: current.enabledIDs.filter { $0 != id },
|
||||
confirmedShortcutIDs: current.confirmedShortcutIDs
|
||||
@@ -75,27 +94,34 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
|
||||
/// 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 {
|
||||
guard let skill = AIClipboardSkillCatalog.skill(id: id, userCatalog: userCatalog),
|
||||
skill.requiresShortcut else {
|
||||
return .unknown
|
||||
}
|
||||
var current = layout.sanitized()
|
||||
var current = layout.sanitized(catalog: mergedCatalog)
|
||||
if !current.confirmedShortcutIDs.contains(id) {
|
||||
current.confirmedShortcutIDs.append(id)
|
||||
}
|
||||
commit(current)
|
||||
commitLayout(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 }
|
||||
let ids = layout.sanitized(catalog: mergedCatalog).enabledIDs
|
||||
guard let to = ids.firstIndex(of: targetID) else { return }
|
||||
moveEnabled(id: draggedID, toIndex: to)
|
||||
}
|
||||
|
||||
public func moveEnabled(id draggedID: String, toIndex: Int) {
|
||||
var ids = layout.sanitized(catalog: mergedCatalog).enabledIDs
|
||||
guard let from = ids.firstIndex(of: draggedID), !ids.isEmpty else { return }
|
||||
let to = min(max(toIndex, 0), ids.count - 1)
|
||||
guard from != to else { return }
|
||||
ids.move(
|
||||
fromOffsets: IndexSet(integer: from),
|
||||
toOffset: to > from ? to + 1 : to
|
||||
)
|
||||
commit(
|
||||
commitLayout(
|
||||
AIAgentSkillLayout(
|
||||
enabledIDs: ids,
|
||||
confirmedShortcutIDs: layout.confirmedShortcutIDs
|
||||
@@ -103,8 +129,47 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
|
||||
)
|
||||
}
|
||||
|
||||
private func commit(_ layout: AIAgentSkillLayout) {
|
||||
persist(layout)
|
||||
self.layout = load()
|
||||
public func saveUserSkill(_ skill: AIUserSkill) throws {
|
||||
let previousURL = userCatalog.skill(id: skill.id)?.shortcutICloudURL
|
||||
var catalog = userCatalog
|
||||
try catalog.upsert(skill)
|
||||
commitUserCatalog(catalog)
|
||||
if previousURL != nil, previousURL != skill.shortcutICloudURL {
|
||||
dropShortcutConfirmation(for: skill.id)
|
||||
}
|
||||
}
|
||||
|
||||
public func deleteUserSkill(id: String) {
|
||||
var catalog = userCatalog
|
||||
catalog.remove(id: id)
|
||||
commitUserCatalog(catalog)
|
||||
let current = layout.sanitized(catalog: mergedCatalog)
|
||||
commitLayout(
|
||||
AIAgentSkillLayout(
|
||||
enabledIDs: current.enabledIDs.filter { $0 != id },
|
||||
confirmedShortcutIDs: current.confirmedShortcutIDs.filter { $0 != id }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func dropShortcutConfirmation(for id: String) {
|
||||
let current = layout.sanitized(catalog: mergedCatalog)
|
||||
commitLayout(
|
||||
AIAgentSkillLayout(
|
||||
enabledIDs: current.enabledIDs.filter { $0 != id },
|
||||
confirmedShortcutIDs: current.confirmedShortcutIDs.filter { $0 != id }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func commitLayout(_ layout: AIAgentSkillLayout) {
|
||||
persistLayout(layout)
|
||||
self.layout = loadLayout()
|
||||
}
|
||||
|
||||
private func commitUserCatalog(_ catalog: AIUserSkillCatalog) {
|
||||
persistUserCatalog(catalog)
|
||||
userCatalog = loadUserCatalog()
|
||||
layout = loadLayout()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,17 @@ public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
|
||||
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. Custom skills default off.
|
||||
public let thinkingEnabled: Bool
|
||||
|
||||
public var requiresShortcut: Bool { kind == .export }
|
||||
public var isUserCreated: Bool { id.hasPrefix("user.") }
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
@@ -42,7 +51,12 @@ public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
|
||||
kind: AIClipboardSkillKind,
|
||||
isDefault: Bool,
|
||||
shortcutName: String? = nil,
|
||||
shortcutICloudURL: URL? = 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
|
||||
@@ -53,6 +67,11 @@ public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
|
||||
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.") ? thinkingEnabled : false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,8 +82,20 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
public static let extractTodosID = "extractTodos"
|
||||
public static let extractTodosShortcutName = "OSG · 提取待办"
|
||||
public static let extractTodosShortcutICloudURL = URL(
|
||||
string: "https://www.icloud.com/shortcuts/520317da7ae74759b64d5fb069c71f81"
|
||||
string: "https://www.icloud.com/shortcuts/65bf33ba4206484ba78d582eaf1e9c44"
|
||||
)!
|
||||
public static let extractTodosResourceName = "OSGExtractTodos"
|
||||
|
||||
public static let extractEventsID = "extractEvents"
|
||||
public static let extractEventsShortcutName = "OSG · 提取日程"
|
||||
public static let extractEventsShortcutICloudURL = URL(
|
||||
string: "https://www.icloud.com/shortcuts/1f4afcf7ee22400cbf84e319d969aadf"
|
||||
)!
|
||||
public static let extractEventsResourceName = "OSGExtractEvents"
|
||||
|
||||
public static let navigateID = "navigate"
|
||||
public static let navigateShortcutName = "OSG · 导航"
|
||||
public static let navigateResourceName = "OSGNavigate"
|
||||
|
||||
/// Full built-in catalog, in a stable display order for the Skills tab.
|
||||
public static let catalog: [AIClipboardSkill] = [
|
||||
@@ -104,35 +135,76 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
kind: .export,
|
||||
isDefault: false,
|
||||
shortcutName: extractTodosShortcutName,
|
||||
shortcutICloudURL: extractTodosShortcutICloudURL
|
||||
shortcutICloudURL: extractTodosShortcutICloudURL,
|
||||
shortcutResourceName: extractTodosResourceName
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: extractEventsID,
|
||||
systemImage: "calendar",
|
||||
titleKey: "keyboard.ai.skill.extractEvents",
|
||||
cardTitleKey: "skills.extractEvents.name",
|
||||
descriptionKey: "skills.extractEvents.description",
|
||||
kind: .export,
|
||||
isDefault: false,
|
||||
shortcutName: extractEventsShortcutName,
|
||||
shortcutICloudURL: extractEventsShortcutICloudURL,
|
||||
shortcutResourceName: extractEventsResourceName
|
||||
),
|
||||
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: false,
|
||||
shortcutName: navigateShortcutName,
|
||||
shortcutResourceName: navigateResourceName
|
||||
),
|
||||
]
|
||||
|
||||
/// 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 }
|
||||
public static func all(userCatalog: AIUserSkillCatalog = .empty) -> [AIClipboardSkill] {
|
||||
catalog + userCatalog.entries.map { $0.asClipboardSkill() }
|
||||
}
|
||||
|
||||
public static func skill(
|
||||
id: String,
|
||||
userCatalog: AIUserSkillCatalog = .empty
|
||||
) -> AIClipboardSkill? {
|
||||
catalog.first { $0.id == id } ?? userCatalog.skill(id: id)?.asClipboardSkill()
|
||||
}
|
||||
|
||||
/// `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] {
|
||||
public static func visible(
|
||||
enabledIDs: [String]? = nil,
|
||||
userCatalog: AIUserSkillCatalog = .empty
|
||||
) -> [AIClipboardSkill] {
|
||||
let ids = enabledIDs ?? AIAgentSkillLayout.defaultEnabledIDs
|
||||
guard !ids.isEmpty else { return [] }
|
||||
let byID = Dictionary(uniqueKeysWithValues: catalog.map { ($0.id, $0) })
|
||||
let byID = Dictionary(uniqueKeysWithValues: all(userCatalog: userCatalog).map { ($0.id, $0) })
|
||||
return ids.compactMap { byID[$0] }
|
||||
}
|
||||
|
||||
public static func instruction(
|
||||
for skill: AIClipboardSkill,
|
||||
locale: String,
|
||||
translationTargetLocaleId: String
|
||||
translationTargetLocaleId: String,
|
||||
now: Date = Date()
|
||||
) -> String {
|
||||
instruction(
|
||||
if let custom = skill.customPrompt?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!custom.isEmpty {
|
||||
return custom
|
||||
}
|
||||
return instruction(
|
||||
skillID: skill.id,
|
||||
locale: locale,
|
||||
translationTargetLocaleId: translationTargetLocaleId
|
||||
translationTargetLocaleId: translationTargetLocaleId,
|
||||
now: now
|
||||
)
|
||||
}
|
||||
|
||||
@@ -159,7 +231,8 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
public static func instruction(
|
||||
skillID: String,
|
||||
locale: String,
|
||||
translationTargetLocaleId: String
|
||||
translationTargetLocaleId: String,
|
||||
now: Date = Date()
|
||||
) -> String {
|
||||
let zh = locale == "zh"
|
||||
switch skillID {
|
||||
@@ -188,6 +261,10 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
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 navigateID:
|
||||
return navigateInstruction(zh: zh)
|
||||
default:
|
||||
return zh
|
||||
? "请根据剪贴板内容完成用户选择的操作。"
|
||||
@@ -195,6 +272,61 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
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)."
|
||||
}
|
||||
|
||||
/// Uses the keyboard translation target when set; otherwise Chinese ↔ English.
|
||||
private static func translateInstruction(
|
||||
locale: String,
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
// AIEventExtraction.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Parses the LLM's extract-events reply into Shortcut lines. Fail closed:
|
||||
// empty / NONE / lines without a parseable start never become a run.
|
||||
// Canonical line: start|end|title|location
|
||||
// All-day uses end token ALLDAY so the companion Shortcut can branch.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum AIEventExtraction: Sendable {
|
||||
public static let maximumItems = 20
|
||||
public static let defaultDuration: TimeInterval = 3600
|
||||
/// End-field sentinel for all-day events (Shortcut If equals this).
|
||||
public static let allDaySentinel = "ALLDAY"
|
||||
|
||||
private static let emptyTokens: Set<String> = [
|
||||
"none", "no", "n/a", "na", "nil", "null",
|
||||
"无", "没有", "没有日程", "没有事件", "无日程",
|
||||
"没有日期", "没有时间", "没有日期或时间", "没有日期和时间",
|
||||
"no events", "no event", "no calendar events",
|
||||
"no date", "no time", "no date or time", "no date and time",
|
||||
]
|
||||
|
||||
/// Lines to send to the companion Shortcut. Empty → do not run it.
|
||||
public static func lines(
|
||||
from raw: String,
|
||||
sourceClipboard: String? = nil,
|
||||
now: Date = Date(),
|
||||
calendar: Calendar = .current
|
||||
) -> [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) {
|
||||
guard let encoded = encodeLine(
|
||||
line,
|
||||
now: now,
|
||||
calendar: calendar
|
||||
) else { continue }
|
||||
let key = encoded.lowercased()
|
||||
guard seen.insert(key).inserted else { continue }
|
||||
items.append(encoded)
|
||||
if items.count == maximumItems { break }
|
||||
}
|
||||
|
||||
if items.count == 1, isWholeClipboardEcho(items[0], source: sourceClipboard) {
|
||||
return []
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// MARK: - Line
|
||||
|
||||
private static func encodeLine(
|
||||
_ line: String,
|
||||
now: Date,
|
||||
calendar: Calendar
|
||||
) -> String? {
|
||||
let fields = splitFields(line)
|
||||
guard fields.count >= 2 else { return nil }
|
||||
|
||||
let startRaw = fields[0]
|
||||
let endRaw: String
|
||||
let titleRaw: String
|
||||
let locationRaw: String
|
||||
switch fields.count {
|
||||
case 2:
|
||||
endRaw = ""
|
||||
titleRaw = fields[1]
|
||||
locationRaw = ""
|
||||
case 3:
|
||||
// 3 fields: start|end|title if segment 2 is a time/empty;
|
||||
// otherwise start|title|location.
|
||||
if fields[1].isEmpty || parseInstant(fields[1], on: now, calendar: calendar) != nil {
|
||||
endRaw = fields[1]
|
||||
titleRaw = fields[2]
|
||||
locationRaw = ""
|
||||
} else {
|
||||
endRaw = ""
|
||||
titleRaw = fields[1]
|
||||
locationRaw = fields[2]
|
||||
}
|
||||
default:
|
||||
endRaw = fields[1]
|
||||
titleRaw = fields[2]
|
||||
locationRaw = fields[3]
|
||||
}
|
||||
|
||||
let title = stripBullet(titleRaw)
|
||||
guard !title.isEmpty, !isEmptyToken(title) else { return nil }
|
||||
let location = locationRaw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
guard let start = parseInstant(startRaw, on: now, calendar: calendar) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch start {
|
||||
case .allDay(let day):
|
||||
return encode(
|
||||
start: formatDay(day, calendar: calendar),
|
||||
end: allDaySentinel,
|
||||
title: title,
|
||||
location: location
|
||||
)
|
||||
case .timed(let startDate, _):
|
||||
let endDate = resolveEnd(
|
||||
endRaw,
|
||||
start: startDate,
|
||||
calendar: calendar
|
||||
)
|
||||
return encode(
|
||||
start: formatMinute(startDate, calendar: calendar),
|
||||
end: formatMinute(endDate, calendar: calendar),
|
||||
title: title,
|
||||
location: location
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Split on `|` and keep at most 4 fields; extra segments join into location.
|
||||
private static func splitFields(_ line: String) -> [String] {
|
||||
let parts = line
|
||||
.split(separator: "|", omittingEmptySubsequences: false)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
guard parts.count > 4 else { return parts }
|
||||
let location = parts[3...].joined(separator: "|")
|
||||
return [parts[0], parts[1], parts[2], location]
|
||||
}
|
||||
|
||||
private static func encode(start: String, end: String, title: String, location: String) -> String {
|
||||
"\(start)|\(end)|\(title)|\(location)"
|
||||
}
|
||||
|
||||
// MARK: - Time
|
||||
|
||||
private enum Instant {
|
||||
case allDay(Date)
|
||||
case timed(Date, timeOnly: Bool)
|
||||
}
|
||||
|
||||
private static func parseInstant(
|
||||
_ raw: String,
|
||||
on day: Date,
|
||||
calendar: Calendar
|
||||
) -> Instant? {
|
||||
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return nil }
|
||||
if text.caseInsensitiveCompare(allDaySentinel) == .orderedSame {
|
||||
return .allDay(calendar.startOfDay(for: day))
|
||||
}
|
||||
|
||||
for format in ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm"] {
|
||||
if let date = date(from: text, format: format, calendar: calendar) {
|
||||
return .timed(date, timeOnly: false)
|
||||
}
|
||||
}
|
||||
if let date = date(from: text, format: "yyyy-MM-dd", calendar: calendar) {
|
||||
return .allDay(calendar.startOfDay(for: date))
|
||||
}
|
||||
for format in ["HH:mm:ss", "HH:mm", "H:mm"] {
|
||||
if let parsed = date(from: text, format: format, calendar: calendar) {
|
||||
let parts = calendar.dateComponents([.hour, .minute, .second], from: parsed)
|
||||
guard let combined = calendar.date(
|
||||
bySettingHour: parts.hour ?? 0,
|
||||
minute: parts.minute ?? 0,
|
||||
second: 0,
|
||||
of: day
|
||||
) else { return nil }
|
||||
return .timed(combined, timeOnly: true)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func resolveEnd(
|
||||
_ raw: String,
|
||||
start: Date,
|
||||
calendar: Calendar
|
||||
) -> Date {
|
||||
let fallback = start.addingTimeInterval(defaultDuration)
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty,
|
||||
trimmed.caseInsensitiveCompare(allDaySentinel) != .orderedSame else {
|
||||
return fallback
|
||||
}
|
||||
guard let parsed = parseInstant(trimmed, on: start, calendar: calendar) else {
|
||||
return fallback
|
||||
}
|
||||
let end: Date
|
||||
switch parsed {
|
||||
case .allDay(let day):
|
||||
// Date-only end with a timed start: use that calendar day at 23:59.
|
||||
end = calendar.date(bySettingHour: 23, minute: 59, second: 0, of: day) ?? fallback
|
||||
case .timed(let date, let timeOnly):
|
||||
if timeOnly, date <= start {
|
||||
end = calendar.date(byAdding: .day, value: 1, to: date) ?? fallback
|
||||
} else {
|
||||
end = date
|
||||
}
|
||||
}
|
||||
return end <= start ? fallback : end
|
||||
}
|
||||
|
||||
private static func date(from text: String, format: String, calendar: Calendar) -> Date? {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = calendar
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = calendar.timeZone
|
||||
formatter.isLenient = false
|
||||
formatter.dateFormat = format
|
||||
return formatter.date(from: text)
|
||||
}
|
||||
|
||||
private static func formatDay(_ date: Date, calendar: Calendar) -> String {
|
||||
format(date, "yyyy-MM-dd", calendar: calendar)
|
||||
}
|
||||
|
||||
private static func formatMinute(_ date: Date, calendar: Calendar) -> String {
|
||||
format(date, "yyyy-MM-dd HH:mm", calendar: calendar)
|
||||
}
|
||||
|
||||
private static func format(_ date: Date, _ format: String, calendar: Calendar) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = calendar
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = calendar.timeZone
|
||||
formatter.dateFormat = format
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
// MARK: - Tokens
|
||||
|
||||
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 title that is essentially the clipboard body is not an event.
|
||||
private static func isWholeClipboardEcho(_ item: String, source: String?) -> Bool {
|
||||
guard let source, source.count > 80 else { return false }
|
||||
let fields = item.split(separator: "|", omittingEmptySubsequences: false)
|
||||
guard fields.count >= 3 else { return false }
|
||||
let title = String(fields[2])
|
||||
guard title.count > 80 else { return false }
|
||||
let a = collapse(title)
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// AIGenericSkillExport.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Line-oriented Shortcut input for user-created export skills. Built-in
|
||||
// extract-todos / extract-events keep their dedicated parsers.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum AIGenericSkillExport {
|
||||
public static let maximumItems = 20
|
||||
|
||||
public static func items(from answer: String) -> [String] {
|
||||
let trimmed = answer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return [] }
|
||||
if trimmed.compare("NONE", options: .caseInsensitive) == .orderedSame {
|
||||
return []
|
||||
}
|
||||
let lines = trimmed
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
if lines.isEmpty { return [trimmed] }
|
||||
return Array(lines.prefix(maximumItems))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// AIMapNavigation.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Builds one turn-by-turn URL. Provider order is Amap (高德) → Baidu →
|
||||
// Apple Maps. Shortcuts cannot call `canOpenURL`, so the host injects that
|
||||
// check and passes the resulting URL to the companion Shortcut.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum AIMapProvider: String, Equatable, Sendable {
|
||||
case amap
|
||||
case baidu
|
||||
case apple
|
||||
}
|
||||
|
||||
public enum AIMapNavigation: Sendable {
|
||||
public static let sourceApplication = "OSGKeyboard"
|
||||
public static let baiduSource = "ios.osgkeyboard"
|
||||
/// Amap's usual current-location label when we have no coordinates.
|
||||
public static let amapCurrentLocationName = "我的位置"
|
||||
|
||||
public static func provider(canOpen: (URL) -> Bool) -> AIMapProvider {
|
||||
if canOpen(probe("iosamap")) || canOpen(probe("amapuri")) {
|
||||
return .amap
|
||||
}
|
||||
if canOpen(probe("baidumap")) {
|
||||
return .baidu
|
||||
}
|
||||
return .apple
|
||||
}
|
||||
|
||||
public static func url(for route: AIMapRoute, canOpen: (URL) -> Bool) -> URL {
|
||||
switch provider(canOpen: canOpen) {
|
||||
case .amap:
|
||||
if canOpen(probe("iosamap")) {
|
||||
return amapURL(route, useLegacyScheme: true)
|
||||
}
|
||||
return amapURL(route, useLegacyScheme: false)
|
||||
case .baidu:
|
||||
return baiduURL(route)
|
||||
case .apple:
|
||||
return appleURL(route)
|
||||
}
|
||||
}
|
||||
|
||||
/// Keyboard payload is `origin|destination`. Host turns it into one URL.
|
||||
public static func shortcutInput(
|
||||
from encoded: String,
|
||||
canOpen: (URL) -> Bool
|
||||
) -> String? {
|
||||
guard let route = AIAddressExtraction.route(from: encoded) else { return nil }
|
||||
return url(for: route, canOpen: canOpen).absoluteString
|
||||
}
|
||||
|
||||
// MARK: - Providers
|
||||
|
||||
private static func amapURL(_ route: AIMapRoute, useLegacyScheme: Bool) -> URL {
|
||||
let originName = route.origin ?? amapCurrentLocationName
|
||||
let items = [
|
||||
URLQueryItem(name: "sourceApplication", value: sourceApplication),
|
||||
URLQueryItem(name: "sname", value: originName),
|
||||
URLQueryItem(name: "dname", value: route.destination),
|
||||
URLQueryItem(name: "dev", value: "0"),
|
||||
URLQueryItem(name: "t", value: "0"),
|
||||
]
|
||||
if useLegacyScheme {
|
||||
return makeURL(scheme: "iosamap", host: "path", path: nil, items: items)
|
||||
}
|
||||
let url = makeURL(scheme: "amapuri", host: "route", path: "/plan/", items: items)
|
||||
// URLComponents drops the trailing slash; Amap's documented path is `/plan/`.
|
||||
let withSlash = url.absoluteString.replacingOccurrences(
|
||||
of: "://route/plan?",
|
||||
with: "://route/plan/?"
|
||||
)
|
||||
return URL(string: withSlash) ?? url
|
||||
}
|
||||
|
||||
private static func baiduURL(_ route: AIMapRoute) -> URL {
|
||||
var items = [
|
||||
URLQueryItem(name: "destination", value: "name:\(route.destination)"),
|
||||
URLQueryItem(name: "mode", value: "driving"),
|
||||
URLQueryItem(name: "src", value: baiduSource),
|
||||
]
|
||||
if let origin = route.origin {
|
||||
items.insert(
|
||||
URLQueryItem(name: "origin", value: "name:\(origin)"),
|
||||
at: 0
|
||||
)
|
||||
}
|
||||
return makeURL(scheme: "baidumap", host: "map", path: "/direction", items: items)
|
||||
}
|
||||
|
||||
private static func appleURL(_ route: AIMapRoute) -> URL {
|
||||
var items = [
|
||||
URLQueryItem(name: "daddr", value: route.destination),
|
||||
URLQueryItem(name: "dirflg", value: "d"),
|
||||
]
|
||||
if let origin = route.origin {
|
||||
items.insert(URLQueryItem(name: "saddr", value: origin), at: 0)
|
||||
}
|
||||
// `URLComponents(string: "maps://")` keeps the `://` that opening needs.
|
||||
var components = URLComponents(string: "maps://")!
|
||||
components.queryItems = items
|
||||
return components.url ?? URL(string: "maps://")!
|
||||
}
|
||||
|
||||
private static func probe(_ scheme: String) -> URL {
|
||||
URL(string: "\(scheme)://")!
|
||||
}
|
||||
|
||||
private static func makeURL(
|
||||
scheme: String,
|
||||
host: String?,
|
||||
path: String?,
|
||||
items: [URLQueryItem]
|
||||
) -> URL {
|
||||
var components = URLComponents()
|
||||
components.scheme = scheme
|
||||
components.host = host
|
||||
if let path {
|
||||
components.path = path
|
||||
}
|
||||
components.queryItems = items
|
||||
return components.url ?? URL(string: "\(scheme)://")!
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,15 @@
|
||||
import Foundation
|
||||
|
||||
public enum AIModeLLMClientFactory {
|
||||
/// Build an AI-mode client. Thinking is always forced on for this path.
|
||||
/// When `allowWebSearch` is false, returns the plain polish-compatible client.
|
||||
/// Build an AI-mode client. Thinking defaults on for spoken questions.
|
||||
/// Clipboard skills pass `thinkingEnabled` from the skill (built-in: off).
|
||||
public static func make(
|
||||
providerId: String,
|
||||
baseURL: String,
|
||||
apiKey: String,
|
||||
model: String,
|
||||
allowWebSearch: Bool = true,
|
||||
thinkingEnabled: Bool = true,
|
||||
session: URLSession = .shared
|
||||
) -> any LLMClient {
|
||||
let plain = LLMClientFactory.make(
|
||||
@@ -23,10 +24,10 @@ public enum AIModeLLMClientFactory {
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
thinkingEnabled: true,
|
||||
thinkingEnabled: thinkingEnabled,
|
||||
session: session
|
||||
)
|
||||
guard allowWebSearch else { return plain }
|
||||
guard thinkingEnabled, allowWebSearch else { return plain }
|
||||
|
||||
guard let searching = makeSearchingClient(
|
||||
providerId: providerId,
|
||||
|
||||
@@ -123,7 +123,8 @@ public struct AIQuestionService: Sendable {
|
||||
|
||||
public static func configured(
|
||||
store: any ConfigurationStore,
|
||||
conversations: AIConversationStore
|
||||
conversations: AIConversationStore,
|
||||
thinkingEnabled: Bool = true
|
||||
) throws -> AIQuestionService {
|
||||
// Same provider + baseURL + model resolution as dictation polish so the
|
||||
// Settings LLM card is the single source of truth for both modes.
|
||||
@@ -151,7 +152,8 @@ public struct AIQuestionService: Sendable {
|
||||
baseURL: endpoint.baseURL,
|
||||
apiKey: apiKey,
|
||||
model: endpoint.model,
|
||||
allowWebSearch: true
|
||||
allowWebSearch: true,
|
||||
thinkingEnabled: thinkingEnabled
|
||||
),
|
||||
conversations: conversations,
|
||||
responseLength: store.aiResponseLength
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// AIShortcutShareLink.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Validates iCloud Shortcut share URLs and reads the published name from
|
||||
// Apple's undocumented records endpoint. Running a Shortcut still requires
|
||||
// that name (`shortcuts://run-shortcut?name=`); the share link is install-only.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum AIShortcutShareLink {
|
||||
/// `https://www.icloud.com/shortcuts/{token}` or `https://icloud.com/shortcuts/{token}`.
|
||||
public static func isValid(_ url: URL) -> Bool {
|
||||
guard let token = AIAgentShortcutRun.iCloudShareToken(from: url) else { return false }
|
||||
let allowed = CharacterSet.alphanumerics
|
||||
return (16...64).contains(token.count)
|
||||
&& token.unicodeScalars.allSatisfy { allowed.contains($0) }
|
||||
}
|
||||
|
||||
public static func parse(_ raw: String) -> URL? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let withScheme: String
|
||||
if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") {
|
||||
withScheme = trimmed
|
||||
} else {
|
||||
withScheme = "https://\(trimmed)"
|
||||
}
|
||||
guard let url = URL(string: withScheme), isValid(url) else { return nil }
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
public enum AIShortcutShareMetadataError: Error, Equatable, Sendable {
|
||||
case invalidLink
|
||||
case network
|
||||
case missingName
|
||||
}
|
||||
|
||||
public enum AIShortcutShareMetadata {
|
||||
public static func recordsURL(for shareURL: URL) -> URL? {
|
||||
guard let token = AIAgentShortcutRun.iCloudShareToken(from: shareURL) else { return nil }
|
||||
return URL(string: "https://www.icloud.com/shortcuts/api/records/\(token)")
|
||||
}
|
||||
|
||||
/// Parses `fields.name.value`, or the older `records[0].fields.name.value`.
|
||||
public static func name(fromRecordsJSON data: Data) throws -> String {
|
||||
let object = try JSONSerialization.jsonObject(with: data)
|
||||
guard let root = object as? [String: Any] else {
|
||||
throw AIShortcutShareMetadataError.missingName
|
||||
}
|
||||
if let name = stringName(in: root) {
|
||||
return name
|
||||
}
|
||||
if let records = root["records"] as? [[String: Any]],
|
||||
let first = records.first,
|
||||
let name = stringName(in: first) {
|
||||
return name
|
||||
}
|
||||
throw AIShortcutShareMetadataError.missingName
|
||||
}
|
||||
|
||||
public static func fetchName(
|
||||
from shareURL: URL,
|
||||
session: URLSession = .shared
|
||||
) async throws -> String {
|
||||
guard let url = recordsURL(for: shareURL) else {
|
||||
throw AIShortcutShareMetadataError.invalidLink
|
||||
}
|
||||
let data: Data
|
||||
do {
|
||||
let (bytes, response) = try await session.data(from: url)
|
||||
guard let http = response as? HTTPURLResponse,
|
||||
(200..<300).contains(http.statusCode) else {
|
||||
throw AIShortcutShareMetadataError.network
|
||||
}
|
||||
data = bytes
|
||||
} catch let error as AIShortcutShareMetadataError {
|
||||
throw error
|
||||
} catch {
|
||||
throw AIShortcutShareMetadataError.network
|
||||
}
|
||||
return try name(fromRecordsJSON: data)
|
||||
}
|
||||
|
||||
private static func stringName(in record: [String: Any]) -> String? {
|
||||
guard let fields = record["fields"] as? [String: Any],
|
||||
let nameField = fields["name"] as? [String: Any],
|
||||
let value = nameField["value"] as? String else {
|
||||
return nil
|
||||
}
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,11 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
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)
|
||||
Self.decodeAgentSkillLayout(from: defaults, userCatalog: agentUserSkillCatalog)
|
||||
}
|
||||
|
||||
public var agentUserSkillCatalog: AIUserSkillCatalog {
|
||||
Self.decodeUserSkillCatalog(from: defaults)
|
||||
}
|
||||
|
||||
// MARK: - Writes
|
||||
@@ -205,7 +209,9 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
|
||||
public func setAgentSkillLayout(_ layout: AIAgentSkillLayout) {
|
||||
do {
|
||||
let data = try JSONEncoder().encode(layout.sanitized())
|
||||
let data = try JSONEncoder().encode(
|
||||
layout.sanitized(catalog: AIClipboardSkillCatalog.all(userCatalog: agentUserSkillCatalog))
|
||||
)
|
||||
defaults.set(data, forKey: AppGroupConfiguration.Keys.agentSkillLayout)
|
||||
} catch {
|
||||
OSGLog.config.warning("agentSkillLayout encode failed: \(error.localizedDescription, privacy: .public)")
|
||||
@@ -213,6 +219,20 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
public func setAgentUserSkillCatalog(_ catalog: AIUserSkillCatalog) {
|
||||
do {
|
||||
defaults.set(
|
||||
try JSONEncoder().encode(catalog),
|
||||
forKey: AppGroupConfiguration.Keys.agentUserSkillCatalog
|
||||
)
|
||||
} catch {
|
||||
OSGLog.config.warning(
|
||||
"agentUserSkillCatalog 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) {
|
||||
@@ -244,18 +264,37 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
return payload
|
||||
}
|
||||
|
||||
private static func decodeAgentSkillLayout(from defaults: UserDefaults) -> AIAgentSkillLayout {
|
||||
private static func decodeAgentSkillLayout(
|
||||
from defaults: UserDefaults,
|
||||
userCatalog: AIUserSkillCatalog
|
||||
) -> AIAgentSkillLayout {
|
||||
let catalog = AIClipboardSkillCatalog.all(userCatalog: userCatalog)
|
||||
guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentSkillLayout) else {
|
||||
return .default
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(AIAgentSkillLayout.self, from: data).sanitized()
|
||||
return try JSONDecoder().decode(AIAgentSkillLayout.self, from: data)
|
||||
.sanitized(catalog: catalog)
|
||||
} catch {
|
||||
OSGLog.config.warning("agentSkillLayout decode failed: \(error.localizedDescription, privacy: .public)")
|
||||
return .default
|
||||
}
|
||||
}
|
||||
|
||||
private static func decodeUserSkillCatalog(from defaults: UserDefaults) -> AIUserSkillCatalog {
|
||||
guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentUserSkillCatalog) else {
|
||||
return .empty
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(AIUserSkillCatalog.self, from: data)
|
||||
} catch {
|
||||
OSGLog.config.warning(
|
||||
"agentUserSkillCatalog decode failed: \(error.localizedDescription, privacy: .public)"
|
||||
)
|
||||
return .empty
|
||||
}
|
||||
}
|
||||
|
||||
public var hasCompletedOnboarding: Bool {
|
||||
get { configuration.hasCompletedOnboarding }
|
||||
set { setHasCompletedOnboarding(newValue) }
|
||||
|
||||
Reference in New Issue
Block a user