498f407585
Introduce optional Apple account-backed credits with scoped gateway access while preserving local and BYOK paths. Refresh assistant behavior, tests, privacy disclosures, docs, and the website for the 2.0 experience.
184 lines
6.2 KiB
Swift
184 lines
6.2 KiB
Swift
// AIUserSkill.swift
|
|
// OSGKeyboard · Shared
|
|
//
|
|
// User-created clipboard skills. Built-in skills stay in
|
|
// `AIClipboardSkillCatalog`; this catalog is persisted in App Group so the
|
|
// keyboard can resolve custom names, prompts, and Shortcut run names.
|
|
|
|
import Foundation
|
|
|
|
public struct AIUserSkill: Codable, Equatable, Identifiable, Sendable {
|
|
public let id: String
|
|
public var name: String
|
|
/// Card subtitle. Empty is allowed; the UI shows a generic fallback.
|
|
public var summary: String
|
|
public var systemImage: String
|
|
public var prompt: String
|
|
/// Optional iCloud share URL. Nil means the skill only transforms text.
|
|
public var shortcutICloudURL: URL?
|
|
/// Name used by `shortcuts://run-shortcut?name=`. Independent of `name`.
|
|
public var shortcutName: String
|
|
/// Per-skill reasoning. Built-in skills are always off; custom defaults off.
|
|
public var thinkingEnabled: Bool
|
|
public let createdAt: Date
|
|
public var updatedAt: Date
|
|
|
|
public init(
|
|
id: String = "user.\(UUID().uuidString.lowercased())",
|
|
name: String,
|
|
summary: String = "",
|
|
systemImage: String = AIUserSkillLimits.defaultSystemImage,
|
|
prompt: String,
|
|
shortcutICloudURL: URL? = nil,
|
|
shortcutName: String = "",
|
|
thinkingEnabled: Bool = false,
|
|
createdAt: Date = Date(),
|
|
updatedAt: Date? = nil
|
|
) {
|
|
self.id = id
|
|
self.name = name
|
|
self.summary = summary
|
|
self.systemImage = systemImage
|
|
self.prompt = prompt
|
|
self.shortcutICloudURL = shortcutICloudURL
|
|
self.shortcutName = shortcutName
|
|
self.thinkingEnabled = thinkingEnabled
|
|
self.createdAt = createdAt
|
|
self.updatedAt = updatedAt ?? createdAt
|
|
}
|
|
|
|
public var isUserCreated: Bool { id.hasPrefix("user.") }
|
|
|
|
public func asClipboardSkill() -> AIClipboardSkill {
|
|
let exportsToShortcut = shortcutICloudURL != nil
|
|
return AIClipboardSkill(
|
|
id: id,
|
|
systemImage: systemImage,
|
|
titleKey: "",
|
|
cardTitleKey: "",
|
|
descriptionKey: "",
|
|
kind: exportsToShortcut ? .export : .transform,
|
|
isDefault: false,
|
|
shortcutName: exportsToShortcut ? shortcutName : nil,
|
|
shortcutICloudURL: shortcutICloudURL,
|
|
customName: name,
|
|
customSummary: summary,
|
|
customPrompt: prompt,
|
|
thinkingEnabled: thinkingEnabled
|
|
)
|
|
}
|
|
}
|
|
|
|
public enum AIUserSkillLimits {
|
|
public static let defaultSystemImage = "sparkles"
|
|
public static let maximumPromptCharacters = 6_000
|
|
public static let maximumNameCharacters = 40
|
|
public static let maximumSummaryCharacters = 200
|
|
public static let newPromptTemplate = """
|
|
请根据剪贴板内容完成以下操作。
|
|
只输出结果,不要解释或客套。
|
|
"""
|
|
|
|
/// Curated SF Symbols for the skill-icon picker.
|
|
public static let symbolChoices: [String] = [
|
|
"sparkles",
|
|
"wand.and.stars",
|
|
"text.badge.checkmark",
|
|
"checklist",
|
|
"calendar",
|
|
"envelope.fill",
|
|
"bubble.left.and.bubble.right.fill",
|
|
"character.bubble.fill",
|
|
"doc.text.magnifyingglass",
|
|
"arrowshape.turn.up.left.fill",
|
|
"lightbulb.fill",
|
|
"star.fill",
|
|
"heart.fill",
|
|
"flag.fill",
|
|
"tag.fill",
|
|
"folder.fill",
|
|
"list.bullet",
|
|
"square.and.pencil",
|
|
"scissors",
|
|
"globe",
|
|
"paperplane.fill",
|
|
"clock.fill",
|
|
"bell.fill",
|
|
"bookmark.fill",
|
|
"person.fill",
|
|
"link",
|
|
"number",
|
|
"at",
|
|
"tray.fill",
|
|
"quote.bubble.fill"
|
|
]
|
|
}
|
|
|
|
public enum AIUserSkillValidationError: Error, Equatable, Sendable {
|
|
case emptyName
|
|
case emptyPrompt
|
|
case emptyShortcutName
|
|
case invalidShortcutLink
|
|
case emptyIcon
|
|
case promptTooLong(maximum: Int)
|
|
}
|
|
|
|
public struct AIUserSkillCatalog: Codable, Equatable, Sendable {
|
|
public var entries: [AIUserSkill]
|
|
|
|
public init(entries: [AIUserSkill] = []) {
|
|
self.entries = entries.filter(\.isUserCreated)
|
|
}
|
|
|
|
public static let empty = AIUserSkillCatalog()
|
|
|
|
public func skill(id: String) -> AIUserSkill? {
|
|
entries.first { $0.id == id }
|
|
}
|
|
|
|
public mutating func upsert(_ skill: AIUserSkill, at date: Date = Date()) throws {
|
|
let name = skill.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let summary = skill.summary.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let prompt = skill.prompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let shortcutName = skill.shortcutName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let icon = skill.systemImage.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
guard skill.isUserCreated else { throw AIUserSkillValidationError.emptyName }
|
|
guard !name.isEmpty else { throw AIUserSkillValidationError.emptyName }
|
|
guard !prompt.isEmpty else { throw AIUserSkillValidationError.emptyPrompt }
|
|
guard prompt.count <= AIUserSkillLimits.maximumPromptCharacters else {
|
|
throw AIUserSkillValidationError.promptTooLong(
|
|
maximum: AIUserSkillLimits.maximumPromptCharacters
|
|
)
|
|
}
|
|
if let shortcutURL = skill.shortcutICloudURL {
|
|
guard !shortcutName.isEmpty else {
|
|
throw AIUserSkillValidationError.emptyShortcutName
|
|
}
|
|
guard AIShortcutShareLink.isValid(shortcutURL) else {
|
|
throw AIUserSkillValidationError.invalidShortcutLink
|
|
}
|
|
}
|
|
guard !icon.isEmpty else { throw AIUserSkillValidationError.emptyIcon }
|
|
|
|
var saved = skill
|
|
saved.name = String(name.prefix(AIUserSkillLimits.maximumNameCharacters))
|
|
saved.summary = String(summary.prefix(AIUserSkillLimits.maximumSummaryCharacters))
|
|
saved.prompt = prompt
|
|
saved.shortcutName = shortcutName
|
|
saved.systemImage = icon
|
|
saved.thinkingEnabled = skill.thinkingEnabled
|
|
saved.updatedAt = date
|
|
|
|
if let index = entries.firstIndex(where: { $0.id == skill.id }) {
|
|
entries[index] = saved
|
|
} else {
|
|
entries.append(saved)
|
|
}
|
|
}
|
|
|
|
public mutating func remove(id: String) {
|
|
entries.removeAll { $0.id == id }
|
|
}
|
|
}
|