Files
OSGKeyboard/OSGKeyboardShared/Models/AIUserSkill.swift
Rocky 498f407585 feat(account): add managed credits and cloud gateway
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.
2026-08-20 11:43:21 +08:00

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 }
}
}