feat(keyboard): ship AI hint carousel, home library cards, and clipboard polish

Rotate AI idle suggestions with optional remote packs, move history/dictionary onto self-sizing Home preview cards, harden clipboard capture/prompting, and simplify keyboard chrome by dropping most liquid-glass shadows.
This commit is contained in:
Rocky
2026-08-13 01:00:51 +08:00
parent fd6e0d3e7e
commit 9f308fadd2
202 changed files with 10897 additions and 5962 deletions
@@ -0,0 +1,193 @@
// AIHintKeywordCompressor.swift
// OSGKeyboard · Shared
//
// Uses the user's polish LLM to compress remote hint titles into one-line
// display labels. Failure leaves the previous ready pack untouched (caller).
import Foundation
public struct AIHintKeywordCompressor: Sendable {
private let client: LLMClient?
private let timeout: TimeInterval
public init(client: LLMClient? = nil, timeout: TimeInterval = 45) {
self.client = client
self.timeout = timeout
}
public func compress(
cards: [AIHintCard],
locale: String
) async -> [AIHintCard] {
let candidates = cards.filter { shouldCompress($0) }
guard !candidates.isEmpty else { return cards }
do {
let client = try resolveClient()
let payload = candidates.map {
[
"id": $0.id,
"text": $0.displayText,
"category": $0.category,
"source": $0.source,
]
}
let json = try JSONSerialization.data(withJSONObject: payload)
let jsonText = String(data: json, encoding: .utf8) ?? "[]"
let system = Self.systemPrompt(locale: locale)
let raw = try await withThrowingTaskGroup(of: String.self) { group in
group.addTask {
try await client.polish(jsonText, systemPrompt: system)
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
throw CancellationError()
}
let result = try await group.next()!
group.cancelAll()
return result
}
let mapping = Self.parseDisplayMap(from: raw)
guard !mapping.isEmpty else { return cards }
return cards.map { card in
guard let display = mapping[card.id], !display.isEmpty else { return card }
var copy = card
copy.displayText = Self.sanitizeDisplay(display, locale: locale)
return copy
}
} catch {
#if DEBUG
print("⚠️ [AIHintKeywordCompressor] failed: \(error)")
#endif
return cards.map { card in
var copy = card
copy.displayText = Self.fallbackTruncate(card.displayText, locale: locale)
return copy
}
}
}
private func shouldCompress(_ card: AIHintCard) -> Bool {
if isHistoricalToday(card) { return false }
if card.locale == "zh" || card.displayText.contains(where: { $0.isCJKUnifiedIdeograph }) {
return card.displayText.count > 12 || card.displayText.contains("")
|| card.displayText.contains("全网热点")
}
return card.displayText.count > 28
}
private func isHistoricalToday(_ card: AIHintCard) -> Bool {
let haystack = card.displayText + card.prompt
return haystack.contains("历史上的今天")
|| haystack.localizedCaseInsensitiveContains("on this day")
}
private func resolveClient() throws -> LLMClient {
if let client { return client }
let store = AppGroupStore()
let apiKey = store.apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !apiKey.isEmpty else { throw LLMError.noAPIKey }
let providerId = store.providerId
let preset = LLMProvider.provider(id: providerId)
let baseURL = store.baseURL.isEmpty ? preset.defaultBaseURL : store.baseURL
let model = store.model.isEmpty ? preset.defaultModel : store.model
return LLMClientFactory.make(
providerId: providerId,
baseURL: baseURL,
apiKey: apiKey,
model: model,
thinkingEnabled: store.llmThinkingEnabled
)
}
private static func systemPrompt(locale: String) -> String {
if locale == "zh" {
return """
你是输入法 AI 空闲轮播的文案压缩器。
输入是 JSON 数组,每项含 id/text/category/source。
输出 JSON 数组,每项仅 {"id","displayText"}。
硬性规则:
- displayText 必须单行,不要省略号结尾
- 中文约 5–12 字
- 按意图选句式,禁止统一加「聊聊」前缀:
· 讨论类热点 →「聊聊+实体」
· 剪贴板动作 →「帮我回复剪贴板」「把剪贴板译成英文」等
· 天气查询 →「上海天气怎么样」
· 早报/行情 →「看今日早报」「今天大盘如何」
· 生成类 →「来句今日金句」「讲个有趣概念」
· 节日 →「中秋节怎么过」
- 丢弃「历史上的今天」类条目(不要输出它们的 id)
- 不要改写 prompt;不要 Markdown;只输出 JSON
"""
}
return """
You compress AI keyboard idle hint titles.
Input: JSON array of {id,text,category,source}.
Output: JSON array of {"id","displayText"} only.
Rules:
- displayText must be one line, no trailing ellipsis
- English: ≤28 characters, NO "Chat"/"Chat about" prefix
- Match intent (action / query / discuss) with a short natural label
- Drop "On this day" / historical-today style items (omit their ids)
- Do not change prompts; JSON only, no Markdown
"""
}
public static func parseDisplayMap(from raw: String) -> [String: String] {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard let slice = extractJSONArray(from: trimmed) ?? Optional(trimmed),
let data = slice.data(using: .utf8),
let rows = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
else { return [:] }
var map: [String: String] = [:]
for row in rows {
guard let id = row["id"] as? String,
let display = row["displayText"] as? String
else { continue }
let cleaned = display.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleaned.isEmpty else { continue }
map[id] = cleaned
}
return map
}
private static func extractJSONArray(from text: String) -> String? {
guard let start = text.firstIndex(of: "["),
let end = text.lastIndex(of: "]"),
start < end
else { return nil }
return String(text[start...end])
}
public static func sanitizeDisplay(_ text: String, locale: String) -> String {
var value = text
.replacingOccurrences(of: "\n", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
while value.hasSuffix("") || value.hasSuffix("...") {
if value.hasSuffix("...") {
value = String(value.dropLast(3))
} else {
value = String(value.dropLast())
}
value = value.trimmingCharacters(in: .whitespacesAndNewlines)
}
return fallbackTruncate(value, locale: locale)
}
public static func fallbackTruncate(_ text: String, locale: String) -> String {
let limit = locale == "zh" ? 12 : 28
guard text.count > limit else { return text }
return String(text.prefix(limit))
}
}
private extension Character {
var isCJKUnifiedIdeograph: Bool {
unicodeScalars.contains { scalar in
(0x4E00...0x9FFF).contains(scalar.value)
}
}
}