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
@@ -1,8 +1,8 @@
// 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).
// Optional LLM pass after deterministic keyword extraction. Failure leaves
// the extracted labels in place (hard truncate only as a last resort).
import Foundation
@@ -19,8 +19,15 @@ public struct AIHintKeywordCompressor: Sendable {
cards: [AIHintCard],
locale: String
) async -> [AIHintCard] {
let candidates = cards.filter { shouldCompress($0) }
guard !candidates.isEmpty else { return cards }
let prepared = cards.map { card -> AIHintCard in
var copy = card
copy.displayText = AIHintKeywordExtractor.displayText(for: card)
return copy
}
let candidates = zip(cards, prepared).compactMap { original, extracted -> AIHintCard? in
shouldCompress(extracted) ? original : nil
}
guard !candidates.isEmpty else { return prepared }
do {
let client = try resolveClient()
@@ -28,6 +35,7 @@ public struct AIHintKeywordCompressor: Sendable {
[
"id": $0.id,
"text": $0.displayText,
"title": $0.metadata?.title ?? "",
"category": $0.category,
"source": $0.source,
]
@@ -48,32 +56,27 @@ public struct AIHintKeywordCompressor: Sendable {
return result
}
let mapping = Self.parseDisplayMap(from: raw)
guard !mapping.isEmpty else { return cards }
return cards.map { card in
guard !mapping.isEmpty else { return prepared }
return prepared.map { card in
guard let display = mapping[card.id], !display.isEmpty else { return card }
var copy = card
copy.displayText = Self.sanitizeDisplay(display, locale: locale)
copy.displayText = AIHintKeywordExtractor.finalize(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
}
return prepared
}
}
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
if card.requiresClipboard30s { return false }
let limit = AIHintKeywordExtractor.characterLimit(locale: card.locale)
return card.displayText.count > limit
|| card.displayText.contains("")
}
private func isHistoricalToday(_ card: AIHintCard) -> Bool {
@@ -103,34 +106,30 @@ public struct AIHintKeywordCompressor: Sendable {
private static func systemPrompt(locale: String) -> String {
if locale == "zh" {
return """
你是输入法 AI 空闲轮播的文案压缩器。
输入是 JSON 数组,每项含 id/text/category/source。
你是输入法 AI 空闲轮播的关键词提取器。
输入是 JSON 数组,每项含 id/text/title/category/source。
输出 JSON 数组,每项仅 {"id","displayText"}。
硬性规则:
- displayText 必须单行,不要省略号结尾
- 中文约 512 字
- 按意图选句式,禁止统一加「聊聊」前缀:
· 讨论类热点 →「聊聊+实体」
· 剪贴板动作 →「帮我回复剪贴板」「把剪贴板译成英文」等
· 天气查询 →「上海天气怎么样」
· 早报/行情 →「看今日早报」「今天大盘如何」
· 生成类 →「来句今日金句」「讲个有趣概念」
· 节日 →「中秋节怎么过」
- displayText 必须是实体/关键词,不要写成问句或动作句
- 禁止「聊聊」「看看」「帮我」等动词前缀
- 单行,不要省略号结尾
- 中文约 4–10 字;优先用 title 字段
- 丢弃「历史上的今天」类条目(不要输出它们的 id)
- 不要改写 prompt;不要 Markdown;只输出 JSON
"""
}
return """
You compress AI keyboard idle hint titles.
Input: JSON array of {id,text,category,source}.
You extract keywords for AI keyboard idle hint chips.
Input: JSON array of {id,text,title,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)
- displayText is the entity/keyword, not a question or action sentence
- No "Chat"/"Chat about" prefix
- One line, no trailing ellipsis
- English: ≤22 characters; prefer the title field
- Drop "On this day" / historical-today items (omit their ids)
- Do not change prompts; JSON only, no Markdown
"""
}
@@ -163,31 +162,10 @@ public struct AIHintKeywordCompressor: Sendable {
}
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)
AIHintKeywordExtractor.finalize(text, 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)
}
AIHintKeywordExtractor.finalize(text, locale: locale)
}
}