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.
260 lines
9.7 KiB
Swift
260 lines
9.7 KiB
Swift
// PolishStylePack.swift
|
|
// OSGKeyboard · Shared
|
|
//
|
|
// Complete writing-personality prompts used by the polish pipeline. Built-in
|
|
// packs ship with the app; only user-created packs are persisted and synced.
|
|
|
|
import Foundation
|
|
|
|
public struct PolishStylePack: Codable, Equatable, Identifiable, Sendable {
|
|
public enum Kind: String, Codable, Sendable {
|
|
case builtin
|
|
case user
|
|
}
|
|
|
|
public let id: String
|
|
public var name: String
|
|
public var prompt: String
|
|
/// When true, polish may keep model-added emoji and the prompt overrides R5.
|
|
/// Defaults off so existing / builtin styles stay emoji-strict.
|
|
public var allowsAddedEmoji: Bool
|
|
public let kind: Kind
|
|
public let createdAt: Date
|
|
public var updatedAt: Date
|
|
|
|
public init(
|
|
id: String = "user.\(UUID().uuidString.lowercased())",
|
|
name: String,
|
|
prompt: String,
|
|
allowsAddedEmoji: Bool = false,
|
|
kind: Kind = .user,
|
|
createdAt: Date = Date(),
|
|
updatedAt: Date? = nil
|
|
) {
|
|
self.id = id
|
|
self.name = name
|
|
self.prompt = prompt
|
|
self.allowsAddedEmoji = allowsAddedEmoji
|
|
self.kind = kind
|
|
self.createdAt = createdAt
|
|
self.updatedAt = updatedAt ?? createdAt
|
|
}
|
|
|
|
public func displayName(language: AppUILanguage? = nil) -> String {
|
|
guard kind == .builtin else { return name }
|
|
return SharedL10n.string("polishStyle.\(id.dropFirst("builtin.".count))", language: language)
|
|
}
|
|
|
|
/// Effective emoji policy for polish: explicit toggle, or a custom prompt that
|
|
/// clearly opts in (so paste-only custom styles still keep model-added emoji).
|
|
public var effectiveAllowsAddedEmoji: Bool {
|
|
if allowsAddedEmoji { return true }
|
|
guard kind == .user else { return false }
|
|
return Self.promptDeclaresAddedEmojiOptIn(prompt)
|
|
}
|
|
|
|
/// Heuristic for custom prompts that declare “add mood emoji” themselves.
|
|
public static func promptDeclaresAddedEmojiOptIn(_ prompt: String) -> Bool {
|
|
let markers = [
|
|
"允许新增 emoji",
|
|
"允许新增emoji",
|
|
"按情绪点缀",
|
|
"按原文情绪",
|
|
"Emoji 覆盖",
|
|
"outranks global R5",
|
|
"may add emojis",
|
|
"allow mood emoji",
|
|
"allowsAddedEmoji"
|
|
]
|
|
return markers.contains { prompt.localizedCaseInsensitiveContains($0) }
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case id, name, prompt, allowsAddedEmoji, kind, createdAt, updatedAt
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
id = try container.decode(String.self, forKey: .id)
|
|
name = try container.decode(String.self, forKey: .name)
|
|
prompt = try container.decode(String.self, forKey: .prompt)
|
|
// Older synced packs omit the key — stay emoji-strict.
|
|
allowsAddedEmoji = try container.decodeIfPresent(Bool.self, forKey: .allowsAddedEmoji) ?? false
|
|
kind = try container.decode(Kind.self, forKey: .kind)
|
|
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
|
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
|
}
|
|
}
|
|
|
|
public enum PolishStyleLimits {
|
|
public static let maximumUserPacks = 8
|
|
public static let maximumPromptCharacters = 6_000
|
|
}
|
|
|
|
public enum PolishStyleValidationError: Error, Equatable, Sendable {
|
|
case emptyName
|
|
case emptyPrompt
|
|
case tooManyUserPacks
|
|
case promptTooLong(maximum: Int)
|
|
case builtinIsImmutable
|
|
}
|
|
|
|
public struct PolishStyleCatalog: Codable, Equatable, Sendable {
|
|
public var entries: [PolishStylePack]
|
|
public var version: Int
|
|
public var lastSyncedAt: Date?
|
|
/// Deletion tombstones prevent an offline device from restoring old packs.
|
|
public var deletedEntryIDs: [String: Date]
|
|
public var clearedAt: Date?
|
|
|
|
public init(
|
|
entries: [PolishStylePack] = [],
|
|
version: Int = 1,
|
|
lastSyncedAt: Date? = nil,
|
|
deletedEntryIDs: [String: Date] = [:],
|
|
clearedAt: Date? = nil
|
|
) {
|
|
self.entries = entries.filter { $0.kind == .user }
|
|
self.version = version
|
|
self.lastSyncedAt = lastSyncedAt
|
|
self.deletedEntryIDs = deletedEntryIDs
|
|
self.clearedAt = clearedAt
|
|
}
|
|
|
|
public static let empty = PolishStyleCatalog()
|
|
|
|
public mutating func upsert(_ pack: PolishStylePack, at date: Date = Date()) throws {
|
|
guard pack.kind == .user else { throw PolishStyleValidationError.builtinIsImmutable }
|
|
let name = pack.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let prompt = pack.prompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !name.isEmpty else { throw PolishStyleValidationError.emptyName }
|
|
guard !prompt.isEmpty else { throw PolishStyleValidationError.emptyPrompt }
|
|
guard prompt.count <= PolishStyleLimits.maximumPromptCharacters else {
|
|
throw PolishStyleValidationError.promptTooLong(maximum: PolishStyleLimits.maximumPromptCharacters)
|
|
}
|
|
|
|
if let index = entries.firstIndex(where: { $0.id == pack.id }) {
|
|
var updated = pack
|
|
updated.name = name
|
|
updated.prompt = prompt
|
|
updated.allowsAddedEmoji = pack.allowsAddedEmoji
|
|
updated.updatedAt = date
|
|
entries[index] = updated
|
|
} else {
|
|
guard entries.count < PolishStyleLimits.maximumUserPacks else {
|
|
throw PolishStyleValidationError.tooManyUserPacks
|
|
}
|
|
var created = pack
|
|
created.name = name
|
|
created.prompt = prompt
|
|
created.allowsAddedEmoji = pack.allowsAddedEmoji
|
|
created.updatedAt = date
|
|
entries.append(created)
|
|
}
|
|
deletedEntryIDs.removeValue(forKey: pack.id)
|
|
version += 1
|
|
}
|
|
|
|
public mutating func recordDeletion(of id: String, at date: Date = Date()) {
|
|
entries.removeAll { $0.id == id }
|
|
deletedEntryIDs[id] = date
|
|
version += 1
|
|
}
|
|
}
|
|
|
|
public enum PolishStylePackCatalog {
|
|
public static let defaultID = "builtin.light"
|
|
public static let newUserPromptTemplate = """
|
|
# 角色
|
|
描述这个风格采用的写作人格、语气和表达习惯。
|
|
|
|
# 风格边界
|
|
描述这种人格最应该做什么,以及绝不能出现什么。
|
|
|
|
# 示例
|
|
提供一条最能代表这个风格的「输入 → 输出」示例。
|
|
"""
|
|
|
|
/// The stable core owns ASR correction, dictionaries, and output safety.
|
|
/// A style pack contributes personality only.
|
|
public static func runtimePersonality(for style: PolishStylePack) -> String {
|
|
// Older synced user packs may still contain the retired placeholder.
|
|
style.prompt
|
|
.replacingOccurrences(of: "{{DICTIONARY}}", with: "")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
/// Built-in packs loaded from `Resources/PolishStyles/` (manifest + per-style JSON).
|
|
public static let builtins: [PolishStylePack] = {
|
|
let loaded = BuiltinPolishStyleLoader.load()
|
|
precondition(!loaded.isEmpty, "Built-in polish styles failed to load from bundle Resources/PolishStyles")
|
|
return loaded
|
|
}()
|
|
|
|
/// Built-in style sections shown in the polish-styles UI.
|
|
public enum BuiltinStyleGroup: String, CaseIterable, Sendable {
|
|
case practical
|
|
case fun
|
|
|
|
public var ids: [String] {
|
|
switch self {
|
|
case .practical:
|
|
return [defaultID, "builtin.structured", "builtin.formal", "builtin.chat"]
|
|
case .fun:
|
|
return ["builtin.dating", "builtin.flex", "builtin.corp", "builtin.diba", "builtin.xhs"]
|
|
}
|
|
}
|
|
|
|
public var packs: [PolishStylePack] {
|
|
ids.compactMap { id in builtins.first { $0.id == id } }
|
|
}
|
|
}
|
|
|
|
public static func resolve(id: String, userCatalog: PolishStyleCatalog) -> PolishStylePack {
|
|
builtins.first(where: { $0.id == id })
|
|
?? userCatalog.entries.first(where: { $0.id == id })
|
|
?? builtins[0]
|
|
}
|
|
|
|
public static func all(userCatalog: PolishStyleCatalog) -> [PolishStylePack] {
|
|
builtins + userCatalog.entries.sorted {
|
|
if $0.updatedAt != $1.updatedAt { return $0.updatedAt > $1.updatedAt }
|
|
return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
|
|
}
|
|
}
|
|
|
|
public static func isValidActiveID(_ id: String, userCatalog: PolishStyleCatalog) -> Bool {
|
|
builtins.contains(where: { $0.id == id }) || userCatalog.entries.contains(where: { $0.id == id })
|
|
}
|
|
|
|
/// Fun personality packs that fully rewrite voice (dating / flex / corp / diba / xhs).
|
|
public static func isFunPersonality(id: String) -> Bool {
|
|
BuiltinStyleGroup.fun.ids.contains(id)
|
|
}
|
|
|
|
/// Heavy fun styles bypass practical safeguards and use only the shared
|
|
/// transcript formatter before their personality prompt.
|
|
public static func usesFormattingOnlyPipeline(
|
|
id: String,
|
|
intensity: PolishIntensity
|
|
) -> Bool {
|
|
intensity == .heavy && isFunPersonality(id: id)
|
|
}
|
|
|
|
/// SF Symbol shown on polish-style cards (built-in and user packs).
|
|
public static func systemImage(for id: String) -> String {
|
|
switch id {
|
|
case "builtin.structured": return "list.bullet.rectangle"
|
|
case "builtin.formal": return "briefcase"
|
|
case "builtin.dating": return "heart.text.square"
|
|
case "builtin.chat": return "bubble.left.and.bubble.right"
|
|
case "builtin.light": return "wand.and.sparkles"
|
|
case "builtin.flex": return "textformat"
|
|
case "builtin.corp": return "building.2"
|
|
case "builtin.diba": return "quote.bubble"
|
|
case "builtin.xhs": return "star.bubble"
|
|
default: return "text.badge.star"
|
|
}
|
|
}
|
|
}
|