a8d58d8f0c
Custom polish styles can opt in to emotion-matched emoji (default off), with prompt-level opt-in detection so paste-only styles keep model-added emoji. Also include Volcengine API-Key ASR auth, voice-processing capture, PiP flash fix, and related keyboard Shift/haptics reliability work.
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"
|
|
}
|
|
}
|
|
}
|