Cursor: Apply local changes for cloud agent
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user