Cursor: Apply local changes for cloud agent
This commit is contained in:
@@ -29,7 +29,10 @@ public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
|
||||
}
|
||||
|
||||
public func isEnabled(_ id: String) -> Bool {
|
||||
enabledIDs.contains(id)
|
||||
let canonical = AIClipboardSkillCatalog.canonicalID(for: id)
|
||||
return enabledIDs.contains {
|
||||
AIClipboardSkillCatalog.canonicalID(for: $0) == canonical
|
||||
}
|
||||
}
|
||||
|
||||
public func hasConfirmedShortcut(_ id: String) -> Bool {
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
// AIReplyVariant.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Typed reply choices produced by one clipboard-reply request. Model output
|
||||
// never controls SF Symbols or display labels; only these local allowlists do.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct AIReplyVariant: Equatable, Identifiable, Sendable {
|
||||
public enum Kind: String, CaseIterable, Sendable {
|
||||
case ordinary
|
||||
case formal
|
||||
case playful
|
||||
|
||||
public var systemImage: String {
|
||||
switch self {
|
||||
case .ordinary:
|
||||
return "bubble.left.fill"
|
||||
case .formal:
|
||||
return "briefcase.fill"
|
||||
case .playful:
|
||||
return "theatermasks.fill"
|
||||
}
|
||||
}
|
||||
|
||||
public var titleKey: String {
|
||||
switch self {
|
||||
case .ordinary:
|
||||
return "keyboard.ai.replyVariant.ordinary"
|
||||
case .formal:
|
||||
return "keyboard.ai.replyVariant.formal"
|
||||
case .playful:
|
||||
return "keyboard.ai.replyVariant.playful"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A bounded semantic hint for future feedback and presentation work.
|
||||
/// Unknown model values are intentionally normalized to `.neutral`.
|
||||
public enum Emotion: String, CaseIterable, Sendable {
|
||||
case neutral
|
||||
case warm
|
||||
case celebratory
|
||||
case empathetic
|
||||
case encouraging
|
||||
case grateful
|
||||
case apologetic
|
||||
case reassuring
|
||||
case playful
|
||||
case enthusiastic
|
||||
case calm
|
||||
|
||||
/// The model selects only this semantic enum. SF Symbol names stay
|
||||
/// local and validated so malformed model output cannot control UI.
|
||||
public func systemImage(fallback kind: Kind) -> String {
|
||||
switch self {
|
||||
case .neutral:
|
||||
return kind.systemImage
|
||||
case .warm, .grateful:
|
||||
return "heart.fill"
|
||||
case .celebratory:
|
||||
return "party.popper.fill"
|
||||
case .empathetic:
|
||||
return "heart.text.square.fill"
|
||||
case .encouraging:
|
||||
return "hand.thumbsup.fill"
|
||||
case .apologetic:
|
||||
return "exclamationmark.bubble.fill"
|
||||
case .reassuring:
|
||||
return "checkmark.shield.fill"
|
||||
case .playful:
|
||||
return "theatermasks.fill"
|
||||
case .enthusiastic:
|
||||
return "sparkles"
|
||||
case .calm:
|
||||
return "leaf.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public let id: UUID
|
||||
public let kind: Kind
|
||||
public let emotion: Emotion
|
||||
public let text: String
|
||||
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
kind: Kind,
|
||||
emotion: Emotion,
|
||||
text: String
|
||||
) {
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
self.emotion = emotion
|
||||
self.text = text
|
||||
}
|
||||
}
|
||||
|
||||
public enum AIReplyVariantParsingResult: Equatable, Sendable {
|
||||
case variants([AIReplyVariant])
|
||||
case single(AIReplyVariant)
|
||||
}
|
||||
|
||||
public enum AIReplyVariantParser {
|
||||
/// The only accepted multi-reply wire shape is:
|
||||
/// `{"variants":[{"kind":"ordinary","emotion":"warm","text":"…"}, ...]}`
|
||||
/// with exactly one item of each kind and no additional JSON fields.
|
||||
public static func parse(
|
||||
_ raw: String,
|
||||
sourceText: String? = nil
|
||||
) -> [AIReplyVariant]? {
|
||||
guard let data = raw.data(using: .utf8),
|
||||
let root = try? JSONSerialization.jsonObject(with: data),
|
||||
let object = root as? [String: Any],
|
||||
Set(object.keys) == ["variants"],
|
||||
let items = object["variants"] as? [[String: Any]],
|
||||
items.count == AIReplyVariant.Kind.allCases.count else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var variantsByKind: [AIReplyVariant.Kind: AIReplyVariant] = [:]
|
||||
for item in items {
|
||||
guard Set(item.keys) == ["kind", "emotion", "text"],
|
||||
let rawKind = item["kind"] as? String,
|
||||
let kind = AIReplyVariant.Kind(rawValue: rawKind),
|
||||
variantsByKind[kind] == nil,
|
||||
let rawEmotion = item["emotion"] as? String,
|
||||
let rawText = item["text"] as? String else {
|
||||
return nil
|
||||
}
|
||||
let text = rawText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty,
|
||||
!isSourceEcho(text, sourceText: sourceText) else {
|
||||
return nil
|
||||
}
|
||||
let emotion = AIReplyVariant.Emotion(rawValue: rawEmotion) ?? .neutral
|
||||
variantsByKind[kind] = AIReplyVariant(
|
||||
kind: kind,
|
||||
emotion: emotion,
|
||||
text: text
|
||||
)
|
||||
}
|
||||
|
||||
let ordered = AIReplyVariant.Kind.allCases.compactMap { variantsByKind[$0] }
|
||||
return ordered.count == AIReplyVariant.Kind.allCases.count ? ordered : nil
|
||||
}
|
||||
|
||||
/// Strict multi-reply parsing with a conservative single ordinary fallback.
|
||||
/// Fenced or malformed JSON is never surfaced verbatim to the insertion UI.
|
||||
public static func parseOrFallback(
|
||||
_ raw: String,
|
||||
sourceText: String? = nil
|
||||
) -> AIReplyVariantParsingResult? {
|
||||
if let variants = parse(raw, sourceText: sourceText) {
|
||||
return .variants(variants)
|
||||
}
|
||||
guard let text = fallbackText(from: raw),
|
||||
!isSourceEcho(text, sourceText: sourceText) else {
|
||||
return nil
|
||||
}
|
||||
return .single(
|
||||
AIReplyVariant(kind: .ordinary, emotion: .neutral, text: text)
|
||||
)
|
||||
}
|
||||
|
||||
public static func fallbackText(from raw: String) -> String? {
|
||||
let unfenced = removingCodeFence(from: raw)
|
||||
guard !unfenced.isEmpty else { return nil }
|
||||
|
||||
if let data = unfenced.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data),
|
||||
let extracted = replyText(in: json) {
|
||||
return normalized(extracted)
|
||||
}
|
||||
if let extracted = textValueFromMalformedJSON(unfenced) {
|
||||
return normalized(extracted)
|
||||
}
|
||||
|
||||
guard !looksLikeJSON(unfenced) else { return nil }
|
||||
return normalized(unfenced)
|
||||
}
|
||||
|
||||
private static let preferredTextKeys = [
|
||||
"text", "reply", "response", "content", "message", "answer"
|
||||
]
|
||||
|
||||
private static let metadataValues = Set(
|
||||
AIReplyVariant.Kind.allCases.map(\.rawValue)
|
||||
+ AIReplyVariant.Emotion.allCases.map(\.rawValue)
|
||||
+ ["variants", "kind", "emotion", "text"]
|
||||
)
|
||||
|
||||
/// Rejects responses that merely report the clipboard message back to its
|
||||
/// sender. A bounded LCS catches close paraphrases while still allowing a
|
||||
/// reply to mention a necessary name or short keyword.
|
||||
private static func isSourceEcho(
|
||||
_ candidate: String,
|
||||
sourceText: String?
|
||||
) -> Bool {
|
||||
guard let sourceText else { return false }
|
||||
let source = normalizedComparisonCharacters(sourceText)
|
||||
let reply = normalizedComparisonCharacters(candidate)
|
||||
guard source.count >= 8, reply.count >= 8 else { return false }
|
||||
|
||||
let overlap = longestCommonSubsequenceLength(source, reply)
|
||||
guard overlap >= 8 else { return false }
|
||||
return Double(overlap) / Double(source.count) >= 0.68
|
||||
&& Double(overlap) / Double(reply.count) >= 0.45
|
||||
}
|
||||
|
||||
private static func normalizedComparisonCharacters(
|
||||
_ text: String
|
||||
) -> [Character] {
|
||||
Array(
|
||||
text.lowercased().filter { character in
|
||||
character.unicodeScalars.contains {
|
||||
CharacterSet.alphanumerics.contains($0)
|
||||
}
|
||||
}.prefix(1_000)
|
||||
)
|
||||
}
|
||||
|
||||
private static func longestCommonSubsequenceLength(
|
||||
_ lhs: [Character],
|
||||
_ rhs: [Character]
|
||||
) -> Int {
|
||||
let shorter: [Character]
|
||||
let longer: [Character]
|
||||
if lhs.count <= rhs.count {
|
||||
shorter = lhs
|
||||
longer = rhs
|
||||
} else {
|
||||
shorter = rhs
|
||||
longer = lhs
|
||||
}
|
||||
var previous = Array(repeating: 0, count: shorter.count + 1)
|
||||
var current = previous
|
||||
for longCharacter in longer {
|
||||
for index in shorter.indices {
|
||||
if longCharacter == shorter[index] {
|
||||
current[index + 1] = previous[index] + 1
|
||||
} else {
|
||||
current[index + 1] = max(
|
||||
previous[index + 1],
|
||||
current[index]
|
||||
)
|
||||
}
|
||||
}
|
||||
swap(&previous, ¤t)
|
||||
current = Array(repeating: 0, count: shorter.count + 1)
|
||||
}
|
||||
return previous[shorter.count]
|
||||
}
|
||||
|
||||
private static func replyText(in value: Any) -> String? {
|
||||
if let object = value as? [String: Any] {
|
||||
for key in preferredTextKeys {
|
||||
if let text = object[key] as? String,
|
||||
let normalized = normalized(text) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
for nested in object.values {
|
||||
if let text = replyText(in: nested) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
if let array = value as? [Any] {
|
||||
for nested in array {
|
||||
if let text = replyText(in: nested) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
if let text = value as? String,
|
||||
!metadataValues.contains(text),
|
||||
let normalized = normalized(text) {
|
||||
return normalized
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func removingCodeFence(from raw: String) -> String {
|
||||
var lines = raw
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.components(separatedBy: .newlines)
|
||||
if lines.first?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.hasPrefix("```") == true {
|
||||
lines.removeFirst()
|
||||
}
|
||||
if lines.last?.trimmingCharacters(in: .whitespacesAndNewlines) == "```" {
|
||||
lines.removeLast()
|
||||
}
|
||||
return lines
|
||||
.joined(separator: "\n")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private static func textValueFromMalformedJSON(_ raw: String) -> String? {
|
||||
let keyPattern = #""(?:text|reply|response|content|message|answer)"\s*:\s*("(?:\\.|[^"\\])*")"#
|
||||
if let regex = try? NSRegularExpression(pattern: keyPattern),
|
||||
let match = regex.firstMatch(
|
||||
in: raw,
|
||||
range: NSRange(raw.startIndex..., in: raw)
|
||||
),
|
||||
let range = Range(match.range(at: 1), in: raw) {
|
||||
return decodeJSONString(String(raw[range]))
|
||||
}
|
||||
|
||||
let stringPattern = #""(?:\\.|[^"\\])*""#
|
||||
guard let regex = try? NSRegularExpression(pattern: stringPattern) else {
|
||||
return nil
|
||||
}
|
||||
let candidates = regex.matches(
|
||||
in: raw,
|
||||
range: NSRange(raw.startIndex..., in: raw)
|
||||
).compactMap { match -> String? in
|
||||
guard let range = Range(match.range, in: raw),
|
||||
let decoded = decodeJSONString(String(raw[range])),
|
||||
!metadataValues.contains(decoded) else {
|
||||
return nil
|
||||
}
|
||||
return normalized(decoded)
|
||||
}
|
||||
return candidates.max { $0.count < $1.count }
|
||||
}
|
||||
|
||||
private static func decodeJSONString(_ quoted: String) -> String? {
|
||||
guard let data = quoted.data(using: .utf8) else { return nil }
|
||||
return try? JSONDecoder().decode(String.self, from: data)
|
||||
}
|
||||
|
||||
private static func looksLikeJSON(_ value: String) -> Bool {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.hasPrefix("{") || trimmed.hasPrefix("[")
|
||||
}
|
||||
|
||||
private static func normalized(_ value: String) -> String? {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,10 @@ public struct AISessionState: Equatable, Sendable {
|
||||
public private(set) var conversationID: UUID?
|
||||
public private(set) var activeUtteranceID: UUID?
|
||||
public private(set) var answer: AIAnswer?
|
||||
/// Three reply choices retained until the user taps one whole card.
|
||||
public private(set) var replyVariants: [AIReplyVariant]
|
||||
/// Stable selection hook for future local feedback recording.
|
||||
public private(set) var selectedReplyVariant: AIReplyVariant?
|
||||
/// Live LLM draft while `phase == .generating`. Cleared on final/cancel.
|
||||
public private(set) var draftAnswerText: String?
|
||||
public private(set) var transcript: String
|
||||
@@ -82,6 +86,8 @@ public struct AISessionState: Equatable, Sendable {
|
||||
conversationID: UUID? = nil,
|
||||
activeUtteranceID: UUID? = nil,
|
||||
answer: AIAnswer? = nil,
|
||||
replyVariants: [AIReplyVariant] = [],
|
||||
selectedReplyVariant: AIReplyVariant? = nil,
|
||||
draftAnswerText: String? = nil,
|
||||
transcript: String = "",
|
||||
errorMessage: String? = nil
|
||||
@@ -90,6 +96,8 @@ public struct AISessionState: Equatable, Sendable {
|
||||
self.conversationID = conversationID
|
||||
self.activeUtteranceID = activeUtteranceID
|
||||
self.answer = answer
|
||||
self.replyVariants = replyVariants
|
||||
self.selectedReplyVariant = selectedReplyVariant
|
||||
self.draftAnswerText = draftAnswerText
|
||||
self.transcript = transcript
|
||||
self.errorMessage = errorMessage
|
||||
@@ -110,6 +118,13 @@ public struct AISessionState: Equatable, Sendable {
|
||||
phase == .ready && answer?.deliveryState == .ready
|
||||
}
|
||||
|
||||
public var canSelectReplyVariant: Bool {
|
||||
phase == .ready
|
||||
&& answer == nil
|
||||
&& selectedReplyVariant == nil
|
||||
&& replyVariants.count == AIReplyVariant.Kind.allCases.count
|
||||
}
|
||||
|
||||
public var canSend: Bool {
|
||||
phase == .awaitingSend && answer?.deliveryState == .awaitingSend
|
||||
}
|
||||
@@ -172,12 +187,49 @@ public struct AISessionState: Equatable, Sendable {
|
||||
public mutating func receiveAnswer(_ text: String, utteranceID: UUID) {
|
||||
guard isActive, activeUtteranceID == utteranceID else { return }
|
||||
answer = AIAnswer(text: text)
|
||||
replyVariants = []
|
||||
selectedReplyVariant = nil
|
||||
draftAnswerText = nil
|
||||
phase = .ready
|
||||
activeUtteranceID = nil
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
public mutating func receiveReplyVariants(
|
||||
_ variants: [AIReplyVariant],
|
||||
utteranceID: UUID
|
||||
) {
|
||||
guard isActive, activeUtteranceID == utteranceID else { return }
|
||||
let kinds = Set(variants.map(\.kind))
|
||||
guard variants.count == AIReplyVariant.Kind.allCases.count,
|
||||
kinds == Set(AIReplyVariant.Kind.allCases) else {
|
||||
return
|
||||
}
|
||||
answer = nil
|
||||
replyVariants = AIReplyVariant.Kind.allCases.compactMap { kind in
|
||||
variants.first { $0.kind == kind }
|
||||
}
|
||||
selectedReplyVariant = nil
|
||||
draftAnswerText = nil
|
||||
phase = .ready
|
||||
activeUtteranceID = nil
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// Converts the tapped choice into the existing insertion model. Keeping
|
||||
/// the selected variant exposes kind/emotion for later feedback recording.
|
||||
@discardableResult
|
||||
public mutating func selectReplyVariant(id: UUID) -> AIAnswer? {
|
||||
guard canSelectReplyVariant,
|
||||
let variant = replyVariants.first(where: { $0.id == id }) else {
|
||||
return nil
|
||||
}
|
||||
let selectedAnswer = AIAnswer(id: variant.id, text: variant.text)
|
||||
selectedReplyVariant = variant
|
||||
answer = selectedAnswer
|
||||
return selectedAnswer
|
||||
}
|
||||
|
||||
public mutating func markAnswerInserted(offersSend: Bool) {
|
||||
guard canInsert else { return }
|
||||
answer?.markInserted(offersSend: offersSend)
|
||||
@@ -195,6 +247,8 @@ public struct AISessionState: Equatable, Sendable {
|
||||
public mutating func discardReadyAnswer() {
|
||||
guard phase == .ready else { return }
|
||||
answer = nil
|
||||
replyVariants = []
|
||||
selectedReplyVariant = nil
|
||||
activeUtteranceID = nil
|
||||
draftAnswerText = nil
|
||||
transcript = ""
|
||||
@@ -233,6 +287,9 @@ public struct AISessionState: Equatable, Sendable {
|
||||
}
|
||||
|
||||
private var restingPhase: Phase {
|
||||
if !replyVariants.isEmpty, selectedReplyVariant == nil {
|
||||
return .ready
|
||||
}
|
||||
guard let answer else { return .idle }
|
||||
switch answer.deliveryState {
|
||||
case .ready:
|
||||
|
||||
@@ -44,6 +44,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
public static let keyboardHapticIntensity = "config.keyboardHapticIntensity"
|
||||
public static let polishIntensity = "config.polishIntensity"
|
||||
public static let aiResponseLength = "config.aiResponseLength"
|
||||
public static let multipleReplyVariantsEnabled = "config.multipleReplyVariantsEnabled"
|
||||
public static let llmThinkingEnabled = "config.llmThinkingEnabled"
|
||||
/// When true, the keyboard records system clipboard text into local history.
|
||||
public static let clipboardHistoryEnabled = "config.clipboardHistoryEnabled"
|
||||
@@ -113,6 +114,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
public var polishIntensity: PolishIntensity
|
||||
/// Soft AI-mode answer length preference (medium by default).
|
||||
public var aiResponseLength: AIResponseLength
|
||||
/// When true, Reply may provide several sendable variants.
|
||||
public var multipleReplyVariantsEnabled: Bool
|
||||
/// Enables provider-specific reasoning / thinking controls for polish LLM requests.
|
||||
public var llmThinkingEnabled: Bool
|
||||
/// Opt-in clipboard history capture in the keyboard extension.
|
||||
@@ -314,6 +317,12 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
aiResponseLength: AIResponseLength.resolve(
|
||||
storedRawValue: defaults.string(forKey: Keys.aiResponseLength)
|
||||
),
|
||||
multipleReplyVariantsEnabled: {
|
||||
if defaults.object(forKey: Keys.multipleReplyVariantsEnabled) == nil {
|
||||
return true
|
||||
}
|
||||
return defaults.bool(forKey: Keys.multipleReplyVariantsEnabled)
|
||||
}(),
|
||||
llmThinkingEnabled: defaults.bool(forKey: Keys.llmThinkingEnabled),
|
||||
clipboardHistoryEnabled: defaults.bool(forKey: Keys.clipboardHistoryEnabled),
|
||||
clipboardCandidateBarEnabled: defaults.bool(forKey: Keys.clipboardCandidateBarEnabled),
|
||||
@@ -458,6 +467,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
defaults.set(keyboardHapticIntensity.rawValue, forKey: Keys.keyboardHapticIntensity)
|
||||
defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity)
|
||||
defaults.set(aiResponseLength.rawValue, forKey: Keys.aiResponseLength)
|
||||
defaults.set(multipleReplyVariantsEnabled, forKey: Keys.multipleReplyVariantsEnabled)
|
||||
defaults.set(llmThinkingEnabled, forKey: Keys.llmThinkingEnabled)
|
||||
defaults.set(clipboardHistoryEnabled, forKey: Keys.clipboardHistoryEnabled)
|
||||
defaults.set(clipboardCandidateBarEnabled, forKey: Keys.clipboardCandidateBarEnabled)
|
||||
@@ -538,6 +548,11 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
previous: baseline.aiResponseLength.rawValue,
|
||||
key: Keys.aiResponseLength
|
||||
)
|
||||
set(
|
||||
multipleReplyVariantsEnabled,
|
||||
previous: baseline.multipleReplyVariantsEnabled,
|
||||
key: Keys.multipleReplyVariantsEnabled
|
||||
)
|
||||
set(
|
||||
llmThinkingEnabled,
|
||||
previous: baseline.llmThinkingEnabled,
|
||||
|
||||
@@ -12,12 +12,45 @@ public struct PolishStylePack: Codable, Equatable, Identifiable, Sendable {
|
||||
case user
|
||||
}
|
||||
|
||||
public struct LearningMetadata: Codable, Equatable, Sendable {
|
||||
public let schemaVersion: Int
|
||||
public let evidenceStatus: String
|
||||
public let confidence: Double
|
||||
public let asrExampleCount: Int
|
||||
public let asrEffectiveCharacterCount: Int
|
||||
public let replyExampleCount: Int
|
||||
public let replyFinalEditCount: Int
|
||||
public let generatedAt: Date
|
||||
|
||||
public init(
|
||||
schemaVersion: Int,
|
||||
evidenceStatus: String,
|
||||
confidence: Double,
|
||||
asrExampleCount: Int,
|
||||
asrEffectiveCharacterCount: Int,
|
||||
replyExampleCount: Int,
|
||||
replyFinalEditCount: Int,
|
||||
generatedAt: Date
|
||||
) {
|
||||
self.schemaVersion = schemaVersion
|
||||
self.evidenceStatus = evidenceStatus
|
||||
self.confidence = confidence
|
||||
self.asrExampleCount = asrExampleCount
|
||||
self.asrEffectiveCharacterCount = asrEffectiveCharacterCount
|
||||
self.replyExampleCount = replyExampleCount
|
||||
self.replyFinalEditCount = replyFinalEditCount
|
||||
self.generatedAt = generatedAt
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
/// Optional learning provenance. It is never included in the runtime prompt.
|
||||
public var learningMetadata: LearningMetadata?
|
||||
public let kind: Kind
|
||||
public let createdAt: Date
|
||||
public var updatedAt: Date
|
||||
@@ -27,6 +60,7 @@ public struct PolishStylePack: Codable, Equatable, Identifiable, Sendable {
|
||||
name: String,
|
||||
prompt: String,
|
||||
allowsAddedEmoji: Bool = false,
|
||||
learningMetadata: LearningMetadata? = nil,
|
||||
kind: Kind = .user,
|
||||
createdAt: Date = Date(),
|
||||
updatedAt: Date? = nil
|
||||
@@ -35,6 +69,7 @@ public struct PolishStylePack: Codable, Equatable, Identifiable, Sendable {
|
||||
self.name = name
|
||||
self.prompt = prompt
|
||||
self.allowsAddedEmoji = allowsAddedEmoji
|
||||
self.learningMetadata = learningMetadata
|
||||
self.kind = kind
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt ?? createdAt
|
||||
@@ -70,7 +105,7 @@ public struct PolishStylePack: Codable, Equatable, Identifiable, Sendable {
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, prompt, allowsAddedEmoji, kind, createdAt, updatedAt
|
||||
case id, name, prompt, allowsAddedEmoji, learningMetadata, kind, createdAt, updatedAt
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
@@ -80,6 +115,11 @@ public struct PolishStylePack: Codable, Equatable, Identifiable, Sendable {
|
||||
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
|
||||
// V1 packs have no learning provenance and remain fully decodable.
|
||||
learningMetadata = try container.decodeIfPresent(
|
||||
LearningMetadata.self,
|
||||
forKey: .learningMetadata
|
||||
)
|
||||
kind = try container.decode(Kind.self, forKey: .kind)
|
||||
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
||||
|
||||
@@ -251,6 +251,18 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether Reply may return several sendable variants. Default is on.
|
||||
@Published public var multipleReplyVariantsEnabled: Bool {
|
||||
didSet {
|
||||
guard !isApplyingConfiguration,
|
||||
multipleReplyVariantsEnabled != configuration.multipleReplyVariantsEnabled else {
|
||||
return
|
||||
}
|
||||
configuration.multipleReplyVariantsEnabled = multipleReplyVariantsEnabled
|
||||
persistConfiguration(postConfigChanged: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the pipeline should run translate-and-polish (not just
|
||||
/// polish). Both engines honour the selected target locale.
|
||||
public var isTranslationEffective: Bool {
|
||||
@@ -438,6 +450,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
keyboardHapticIntensity = configuration.keyboardHapticIntensity
|
||||
polishIntensity = configuration.polishIntensity
|
||||
aiResponseLength = configuration.aiResponseLength
|
||||
multipleReplyVariantsEnabled = configuration.multipleReplyVariantsEnabled
|
||||
llmThinkingEnabled = configuration.llmThinkingEnabled
|
||||
clipboardHistoryEnabled = configuration.clipboardHistoryEnabled
|
||||
clipboardCandidateBarEnabled = configuration.clipboardCandidateBarEnabled
|
||||
@@ -469,6 +482,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
keyboardHapticIntensity = .default
|
||||
polishIntensity = .default
|
||||
aiResponseLength = .default
|
||||
multipleReplyVariantsEnabled = true
|
||||
localASRCustomLanguageModelEnabled = true
|
||||
llmThinkingEnabled = false
|
||||
clipboardHistoryEnabled = false
|
||||
@@ -485,6 +499,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
configuration.keyboardHapticIntensity = .default
|
||||
configuration.polishIntensity = .default
|
||||
configuration.aiResponseLength = .default
|
||||
configuration.multipleReplyVariantsEnabled = true
|
||||
configuration.localASRCustomLanguageModelEnabled = true
|
||||
configuration.llmThinkingEnabled = false
|
||||
configuration.clipboardHistoryEnabled = false
|
||||
@@ -543,6 +558,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
keyboardHapticIntensity = fresh.keyboardHapticIntensity
|
||||
polishIntensity = fresh.polishIntensity
|
||||
aiResponseLength = fresh.aiResponseLength
|
||||
multipleReplyVariantsEnabled = fresh.multipleReplyVariantsEnabled
|
||||
llmThinkingEnabled = fresh.llmThinkingEnabled
|
||||
clipboardHistoryEnabled = fresh.clipboardHistoryEnabled
|
||||
clipboardCandidateBarEnabled = fresh.clipboardCandidateBarEnabled
|
||||
|
||||
@@ -29,6 +29,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
public var keyboardHapticIntensity: SyncedField<KeyboardHapticIntensity>
|
||||
public var polishIntensity: SyncedField<PolishIntensity>
|
||||
public var aiResponseLength: SyncedField<AIResponseLength>
|
||||
public var multipleReplyVariantsEnabled: SyncedField<Bool>
|
||||
public var activePolishStyleId: SyncedField<String>
|
||||
public var llmThinkingEnabled: SyncedField<Bool>
|
||||
public var flowSkipAppSwitch: SyncedField<Bool>
|
||||
@@ -53,6 +54,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
keyboardHapticIntensity: SyncedField<KeyboardHapticIntensity>,
|
||||
polishIntensity: SyncedField<PolishIntensity>? = nil,
|
||||
aiResponseLength: SyncedField<AIResponseLength>? = nil,
|
||||
multipleReplyVariantsEnabled: SyncedField<Bool>? = nil,
|
||||
activePolishStyleId: SyncedField<String>,
|
||||
llmThinkingEnabled: SyncedField<Bool>,
|
||||
clipboardHistoryEnabled: SyncedField<Bool>? = nil,
|
||||
@@ -86,6 +88,11 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
updatedAt: keyboardHapticIntensity.updatedAt,
|
||||
deviceID: keyboardHapticIntensity.deviceID
|
||||
)
|
||||
self.multipleReplyVariantsEnabled = multipleReplyVariantsEnabled ?? SyncedField(
|
||||
value: true,
|
||||
updatedAt: keyboardHapticIntensity.updatedAt,
|
||||
deviceID: keyboardHapticIntensity.deviceID
|
||||
)
|
||||
self.activePolishStyleId = activePolishStyleId
|
||||
self.llmThinkingEnabled = llmThinkingEnabled
|
||||
// Kept as optional parameters so old call sites and payload fixtures
|
||||
@@ -115,6 +122,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
case keyboardHapticIntensity
|
||||
case polishIntensity
|
||||
case aiResponseLength
|
||||
case multipleReplyVariantsEnabled
|
||||
case activePolishStyleId
|
||||
case llmThinkingEnabled
|
||||
case clipboardHistoryEnabled
|
||||
@@ -174,6 +182,14 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
updatedAt: keyboardHapticIntensity.updatedAt,
|
||||
deviceID: keyboardHapticIntensity.deviceID
|
||||
)
|
||||
multipleReplyVariantsEnabled = try container.decodeIfPresent(
|
||||
SyncedField<Bool>.self,
|
||||
forKey: .multipleReplyVariantsEnabled
|
||||
) ?? SyncedField(
|
||||
value: true,
|
||||
updatedAt: keyboardHapticIntensity.updatedAt,
|
||||
deviceID: keyboardHapticIntensity.deviceID
|
||||
)
|
||||
activePolishStyleId = try container.decodeIfPresent(
|
||||
SyncedField<String>.self,
|
||||
forKey: .activePolishStyleId
|
||||
@@ -251,6 +267,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
keyboardHapticIntensity.updatedAt,
|
||||
polishIntensity.updatedAt,
|
||||
aiResponseLength.updatedAt,
|
||||
multipleReplyVariantsEnabled.updatedAt,
|
||||
activePolishStyleId.updatedAt,
|
||||
llmThinkingEnabled.updatedAt,
|
||||
flowSkipAppSwitch.updatedAt,
|
||||
@@ -278,6 +295,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
try container.encode(keyboardHapticIntensity, forKey: .keyboardHapticIntensity)
|
||||
try container.encode(polishIntensity, forKey: .polishIntensity)
|
||||
try container.encode(aiResponseLength, forKey: .aiResponseLength)
|
||||
try container.encode(multipleReplyVariantsEnabled, forKey: .multipleReplyVariantsEnabled)
|
||||
try container.encode(activePolishStyleId, forKey: .activePolishStyleId)
|
||||
try container.encode(llmThinkingEnabled, forKey: .llmThinkingEnabled)
|
||||
try container.encode(flowSkipAppSwitch, forKey: .flowSkipAppSwitch)
|
||||
@@ -317,6 +335,7 @@ public extension SyncedAppSettingsV2 {
|
||||
keyboardHapticIntensity: field(configuration.keyboardHapticIntensity),
|
||||
polishIntensity: field(configuration.polishIntensity),
|
||||
aiResponseLength: field(configuration.aiResponseLength),
|
||||
multipleReplyVariantsEnabled: field(configuration.multipleReplyVariantsEnabled),
|
||||
activePolishStyleId: field(configuration.activePolishStyleId),
|
||||
llmThinkingEnabled: field(configuration.llmThinkingEnabled),
|
||||
flowSkipAppSwitch: field(configuration.flowSkipAppSwitch),
|
||||
@@ -349,6 +368,7 @@ public extension SyncedAppSettingsV2 {
|
||||
keyboardHapticIntensity: field(KeyboardHapticIntensity.default),
|
||||
polishIntensity: field(PolishIntensity.default),
|
||||
aiResponseLength: field(AIResponseLength.default),
|
||||
multipleReplyVariantsEnabled: field(true),
|
||||
activePolishStyleId: field(PolishStylePackCatalog.defaultID),
|
||||
llmThinkingEnabled: field(false),
|
||||
flowSkipAppSwitch: field(legacy.flowSkipAppSwitch),
|
||||
@@ -393,6 +413,10 @@ public extension SyncedAppSettingsV2 {
|
||||
local: local.aiResponseLength,
|
||||
remote: remote.aiResponseLength
|
||||
),
|
||||
multipleReplyVariantsEnabled: .merge(
|
||||
local: local.multipleReplyVariantsEnabled,
|
||||
remote: remote.multipleReplyVariantsEnabled
|
||||
),
|
||||
activePolishStyleId: .merge(
|
||||
local: local.activePolishStyleId,
|
||||
remote: remote.activePolishStyleId
|
||||
@@ -424,6 +448,7 @@ public extension SyncedAppSettingsV2 {
|
||||
configuration.keyboardHapticIntensity = keyboardHapticIntensity.value
|
||||
configuration.polishIntensity = polishIntensity.value
|
||||
configuration.aiResponseLength = aiResponseLength.value
|
||||
configuration.multipleReplyVariantsEnabled = multipleReplyVariantsEnabled.value
|
||||
configuration.activePolishStyleId = activePolishStyleId.value
|
||||
configuration.llmThinkingEnabled = llmThinkingEnabled.value
|
||||
configuration.flowSkipAppSwitch = flowSkipAppSwitch.value
|
||||
@@ -454,6 +479,7 @@ public extension SyncedAppSettingsV2 {
|
||||
patch(©.keyboardHapticIntensity, value: configuration.keyboardHapticIntensity)
|
||||
patch(©.polishIntensity, value: configuration.polishIntensity)
|
||||
patch(©.aiResponseLength, value: configuration.aiResponseLength)
|
||||
patch(©.multipleReplyVariantsEnabled, value: configuration.multipleReplyVariantsEnabled)
|
||||
patch(©.activePolishStyleId, value: configuration.activePolishStyleId)
|
||||
patch(©.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
|
||||
patch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
|
||||
@@ -487,6 +513,7 @@ public extension SyncedAppSettingsV2 {
|
||||
touch(©.keyboardHapticIntensity, value: configuration.keyboardHapticIntensity)
|
||||
touch(©.polishIntensity, value: configuration.polishIntensity)
|
||||
touch(©.aiResponseLength, value: configuration.aiResponseLength)
|
||||
touch(©.multipleReplyVariantsEnabled, value: configuration.multipleReplyVariantsEnabled)
|
||||
touch(©.activePolishStyleId, value: configuration.activePolishStyleId)
|
||||
touch(©.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
|
||||
touch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
|
||||
|
||||
Reference in New Issue
Block a user