// AIClipboardPrompt.swift
// OSGKeyboard · Shared
//
// The single place where clipboard text enters an AI prompt. The instruction
// and the clipboard body travel as separate blocks so the body stays untrusted
// data, and every caller must fail closed when no material is available.
import Foundation
public enum AIClipboardPrompt: Sendable {
/// Legacy / remote hint packs may still inline this token in `prompt`.
public static let materialPlaceholder = "{clipboard}"
public enum Resolution: Equatable, Sendable {
case ready(String)
/// The request needs clipboard text and none can be used.
case materialUnavailable
}
/// Instruction + clipboard body in the shared untrusted-data schema.
public static func compose(instruction: String, material: String) -> String {
"""
\(PromptXMLEscaping.escapeTextContent(trimmed(instruction)))
\(PromptXMLEscaping.escapeTextContent(trimmed(material)))
"""
}
/// Resolves a clipboard-dependent instruction. Empty material fails closed
/// instead of asking the model to answer without the text it needs.
public static func resolve(instruction: String, material: String?) -> Resolution {
let body = trimmed(material ?? "")
guard !body.isEmpty else { return .materialUnavailable }
return .ready(
compose(instruction: strippingPlaceholder(instruction), material: body)
)
}
/// Spoken AI questions carry clipboard text only when the user asked for
/// it; every other question is passed through untouched.
public static func resolveSpoken(question: String, material: String?) -> Resolution {
guard mentionsClipboard(question) else { return .ready(question) }
return resolve(instruction: question, material: material)
}
/// True when `text` is the clipboard-AI XML envelope, not user-visible copy.
public static func isInternalPrompt(_ text: String) -> Bool {
text.contains(" String {
trimmed(prompt.replacingOccurrences(of: materialPlaceholder, with: ""))
}
/// Naming the clipboard is the authorization: the user chose the material.
static func mentionsClipboard(_ text: String) -> Bool {
let lowered = text.lowercased()
return keywords.contains { lowered.contains($0) }
}
private static let keywords = [
"剪贴板", "剪切板", "剪贴版", "粘贴板", "clipboard"
]
private static func trimmed(_ text: String) -> String {
text.trimmingCharacters(in: .whitespacesAndNewlines)
}
}