feat(keyboard): add clipboard AI skills, hint keywords, and voice session fixes

Idle chips show entities with category icons; a fresh copy surfaces Reply/Summarize/Translate; abort/cancel/empty-tap no longer leave the mic stuck.
This commit is contained in:
Rocky
2026-08-13 15:47:32 +08:00
parent 9f308fadd2
commit 631617ee53
48 changed files with 1627 additions and 345 deletions
@@ -0,0 +1,162 @@
// AIHintKeywordExtractor.swift
// OSGKeyboard · Shared
//
// Deterministic idle-chip labels: the icon carries type, the text is the
// entity. LLM compression is only a last resort for leftovers.
import Foundation
public enum AIHintKeywordExtractor: Sendable {
public static func characterLimit(locale: String) -> Int {
locale == "zh" ? 10 : 22
}
public static func displayText(for card: AIHintCard) -> String {
let locale = card.locale == "zh" ? "zh" : "en"
let kind = AIHintVisualKind.resolve(card)
let raw = keyword(for: card, kind: kind, locale: locale)
return finalize(raw, locale: locale)
}
/// Strip prefixes, pick a fitting chunk, then enforce the character cap.
public static func finalize(_ text: String, locale: String) -> String {
var value = stripPrefixes(text, locale: locale)
.replacingOccurrences(of: "\n", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
value = stripTrailingPunctuation(value)
let limit = characterLimit(locale: locale)
if value.count <= limit { return value }
if let clause = firstFittingClause(value, limit: limit) { return clause }
if let latin = leadingLatinPhrase(value, limit: limit) { return latin }
if let chunk = lastFittingChunk(value, limit: limit) { return chunk }
return String(value.prefix(limit))
}
// MARK: - Keyword by kind
private static func keyword(
for card: AIHintCard,
kind: AIHintVisualKind,
locale: String
) -> String {
switch kind {
case .trending:
return firstNonEmpty(card.metadata?.title, card.displayText)
case .weather:
if let city = trimmed(card.metadata?.city) {
if let temp = card.metadata?.tempC {
return "\(city) \(Int(temp.rounded()))°"
}
return city
}
return card.displayText
case .news:
return locale == "zh" ? "今日早报" : "Today's briefing"
case .stocks:
return locale == "zh" ? "今日大盘" : "Markets"
case .calendar:
if locale != "zh", let name = trimmed(card.metadata?.name) {
return name
}
return card.displayText
case .search:
if card.metadata?.soul != nil {
return locale == "zh" ? "今日金句" : "A quote"
}
return card.displayText
}
}
// MARK: - Prefixes
public static func stripPrefixes(_ text: String, locale: String) -> String {
var value = text.trimmingCharacters(in: .whitespacesAndNewlines)
let prefixes = locale == "zh" ? zhPrefixes : enPrefixes
var changed = true
while changed {
changed = false
for prefix in prefixes where value.hasPrefix(prefix) {
value = String(value.dropFirst(prefix.count))
.trimmingCharacters(in: .whitespacesAndNewlines)
changed = true
break
}
}
return value
}
private static let zhPrefixes = [
"全网热点:", "全网热点:", "临近节日:", "临近节日:",
"历史上的今天:", "历史上的今天:", "今日一句:", "今日一句:",
"查百科:", "查百科:", "聊聊", "看看",
]
private static let enPrefixes = [
"Trending: ", "Upcoming: ", "On this day: ",
"Chat about ", "Chat ", "Weather in ",
]
// MARK: - Chunks
private static func firstFittingClause(_ text: String, limit: Int) -> String? {
let separators = CharacterSet(charactersIn: ",。;;,.!??!")
let parts = text.components(separatedBy: separators)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard let first = parts.first, first.count <= limit, first.count >= 2 else {
return nil
}
return first
}
private static func lastFittingChunk(_ text: String, limit: Int) -> String? {
let parts = text.split { $0 == " " || $0 == "" || $0 == ":" }
.map(String.init)
.filter { !$0.isEmpty }
guard let last = parts.last else { return nil }
let cleaned = stripTrailingPunctuation(last)
guard (4...limit).contains(cleaned.count) else { return nil }
return cleaned
}
private static func leadingLatinPhrase(_ text: String, limit: Int) -> String? {
var scalars: [Unicode.Scalar] = []
for scalar in text.unicodeScalars {
let isLatin = (0x41...0x5A).contains(scalar.value)
|| (0x61...0x7A).contains(scalar.value)
|| (0x30...0x39).contains(scalar.value)
|| scalar == "." || scalar == "-"
let isSpace = scalar == " "
if isLatin || (isSpace && !scalars.isEmpty) {
scalars.append(scalar)
} else if !scalars.isEmpty {
break
}
}
var phrase = String(String.UnicodeScalarView(scalars))
.trimmingCharacters(in: .whitespaces)
guard phrase.count >= 2 else { return nil }
if phrase.count <= limit { return phrase }
while phrase.count > limit {
guard let lastSpace = phrase.lastIndex(of: " "), lastSpace > phrase.startIndex else {
return String(phrase.prefix(limit))
}
phrase = String(phrase[..<lastSpace])
}
return phrase
}
private static func stripTrailingPunctuation(_ text: String) -> String {
text.trimmingCharacters(in: CharacterSet(charactersIn: "…。.!??!、,"))
}
private static func firstNonEmpty(_ values: String?...) -> String {
values.compactMap(trimmed).first ?? ""
}
private static func trimmed(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}