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:
@@ -48,6 +48,11 @@ public enum AIClipboardPrompt: Sendable {
|
||||
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("<clipboard_request") || text.contains("clipboard-ai-v1")
|
||||
}
|
||||
|
||||
/// Instruction text with any inline material placeholder removed.
|
||||
static func strippingPlaceholder(_ prompt: String) -> String {
|
||||
trimmed(prompt.replacingOccurrences(of: materialPlaceholder, with: ""))
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// AIClipboardSkill.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Built-in clipboard actions for AI idle. The catalog is an ordered list so
|
||||
// Settings can later persist a subset or permutation without changing the view.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
|
||||
public let id: String
|
||||
public let systemImage: String
|
||||
/// Keyboard.strings key for the short button title.
|
||||
public let titleKey: String
|
||||
|
||||
public init(id: String, systemImage: String, titleKey: String) {
|
||||
self.id = id
|
||||
self.systemImage = systemImage
|
||||
self.titleKey = titleKey
|
||||
}
|
||||
}
|
||||
|
||||
public enum AIClipboardSkillCatalog: Sendable {
|
||||
public static let replyID = "reply"
|
||||
public static let summarizeID = "summarize"
|
||||
public static let translateID = "translate"
|
||||
|
||||
/// Default set, in display order. Future skills append here.
|
||||
public static let builtIn: [AIClipboardSkill] = [
|
||||
AIClipboardSkill(
|
||||
id: replyID,
|
||||
systemImage: "arrowshape.turn.up.left.fill",
|
||||
titleKey: "keyboard.ai.skill.reply"
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: summarizeID,
|
||||
systemImage: "doc.text.magnifyingglass",
|
||||
titleKey: "keyboard.ai.skill.summarize"
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: translateID,
|
||||
systemImage: "character.bubble.fill",
|
||||
titleKey: "keyboard.ai.skill.translate"
|
||||
),
|
||||
]
|
||||
|
||||
/// `enabledIDs` is the future Settings hook: `nil` keeps the built-in list.
|
||||
public static func visible(enabledIDs: [String]? = nil) -> [AIClipboardSkill] {
|
||||
guard let enabledIDs, !enabledIDs.isEmpty else { return builtIn }
|
||||
let byID = Dictionary(uniqueKeysWithValues: builtIn.map { ($0.id, $0) })
|
||||
return enabledIDs.compactMap { byID[$0] }
|
||||
}
|
||||
|
||||
public static func instruction(
|
||||
for skill: AIClipboardSkill,
|
||||
locale: String,
|
||||
translationTargetLocaleId: String
|
||||
) -> String {
|
||||
instruction(
|
||||
skillID: skill.id,
|
||||
locale: locale,
|
||||
translationTargetLocaleId: translationTargetLocaleId
|
||||
)
|
||||
}
|
||||
|
||||
/// Compact Translate-chip label. Unset target → 中英互译; Chinese UI
|
||||
/// targeting 简/繁 → 简繁互转 (avoids「中译中」); otherwise 中译× / To XX.
|
||||
public static func translateButtonTitle(
|
||||
translationTargetLocaleId: String,
|
||||
uiLanguage: AppUILanguage
|
||||
) -> String {
|
||||
let isChineseUI = uiLanguage.resolvedLanguageCode() == "zh-Hans"
|
||||
if TranslationLanguageCatalog.isOff(translationTargetLocaleId) {
|
||||
return isChineseUI ? "中↔英" : "CN↔EN"
|
||||
}
|
||||
let target = TranslationLanguageCatalog.resolve(translationTargetLocaleId)
|
||||
if isChineseUI, target.isChineseScript {
|
||||
return "简↔繁"
|
||||
}
|
||||
if isChineseUI {
|
||||
return "中译\(target.chineseShort)"
|
||||
}
|
||||
return "To \(target.englishShort)"
|
||||
}
|
||||
|
||||
public static func instruction(
|
||||
skillID: String,
|
||||
locale: String,
|
||||
translationTargetLocaleId: String
|
||||
) -> String {
|
||||
let zh = locale == "zh"
|
||||
switch skillID {
|
||||
case replyID:
|
||||
return zh
|
||||
? "请根据剪贴板内容起草一段礼貌、简洁的回复,语气自然,可直接发送。"
|
||||
: "Draft a concise, polite reply the user can send, based on the clipboard text."
|
||||
case summarizeID:
|
||||
return zh
|
||||
? "请概括剪贴板内容的核心意思,保留关键事实与结论,不要改写成可发送的短消息。"
|
||||
: "Summarize the clipboard text: keep the key facts and conclusions; do not rewrite it as a sendable short message."
|
||||
case translateID:
|
||||
return translateInstruction(
|
||||
locale: locale,
|
||||
translationTargetLocaleId: translationTargetLocaleId
|
||||
)
|
||||
default:
|
||||
return zh
|
||||
? "请根据剪贴板内容完成用户选择的操作。"
|
||||
: "Complete the selected action using the clipboard text."
|
||||
}
|
||||
}
|
||||
|
||||
/// Uses the keyboard translation target when set; otherwise Chinese ↔ English.
|
||||
private static func translateInstruction(
|
||||
locale: String,
|
||||
translationTargetLocaleId: String
|
||||
) -> String {
|
||||
let zh = locale == "zh"
|
||||
if !TranslationLanguageCatalog.isOff(translationTargetLocaleId) {
|
||||
let language = TranslationLanguageCatalog.resolve(translationTargetLocaleId)
|
||||
let name = language.promptLanguageName
|
||||
return zh
|
||||
? "请将剪贴板内容翻译成\(name),保留原意与语气。"
|
||||
: "Translate the clipboard text into \(name), preserving meaning and tone."
|
||||
}
|
||||
return zh
|
||||
? "请将剪贴板内容在中文与英文之间互译:若原文主要是中文则译成自然英文,若主要是英文则译成自然中文。保留原意与语气。"
|
||||
: "Translate the clipboard between Chinese and English: if it is primarily Chinese, produce natural English; if primarily English, produce natural Chinese. Preserve meaning and tone."
|
||||
}
|
||||
}
|
||||
@@ -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 必须单行,不要省略号结尾
|
||||
- 中文约 5–12 字
|
||||
- 按意图选句式,禁止统一加「聊聊」前缀:
|
||||
· 讨论类热点 →「聊聊+实体」
|
||||
· 剪贴板动作 →「帮我回复剪贴板」「把剪贴板译成英文」等
|
||||
· 天气查询 →「上海天气怎么样」
|
||||
· 早报/行情 →「看今日早报」「今天大盘如何」
|
||||
· 生成类 →「来句今日金句」「讲个有趣概念」
|
||||
· 节日 →「中秋节怎么过」
|
||||
- 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ public enum AIHintLocalCatalog: Sendable {
|
||||
),
|
||||
AIHintCard(
|
||||
id: "local-zh-encyclopedia",
|
||||
displayText: "讲个有趣概念",
|
||||
displayText: "有趣概念",
|
||||
prompt: "用通俗易懂的中文解释一个有趣但常见的概念,并给一个生活里的例子(4-6 句)。",
|
||||
category: "capability",
|
||||
priority: 40,
|
||||
@@ -53,7 +53,7 @@ public enum AIHintLocalCatalog: Sendable {
|
||||
),
|
||||
AIHintCard(
|
||||
id: "local-zh-stocks",
|
||||
displayText: "今天大盘如何",
|
||||
displayText: "今日大盘",
|
||||
prompt: "请用非专业口吻概括今天 A 股/港股/美股中至少一个市场的整体表现、"
|
||||
+ "可能驱动因素,并提醒这并非投资建议(4-6 句)。",
|
||||
category: "economy",
|
||||
@@ -63,7 +63,7 @@ public enum AIHintLocalCatalog: Sendable {
|
||||
),
|
||||
AIHintCard(
|
||||
id: "local-zh-daily-brief",
|
||||
displayText: "看今日早报",
|
||||
displayText: "今日早报",
|
||||
prompt: "请用中文写一份简洁的「今日早报」:国内外各 2–3 条要点、一条财经/科技、"
|
||||
+ "一条轻松话题;每条一句话,总计不超过 12 句。不确定处请标明。",
|
||||
category: "daily",
|
||||
@@ -73,7 +73,7 @@ public enum AIHintLocalCatalog: Sendable {
|
||||
),
|
||||
AIHintCard(
|
||||
id: "local-zh-quote",
|
||||
displayText: "来句今日金句",
|
||||
displayText: "今日金句",
|
||||
prompt: "请给一句适合今天分享的中文金句,并附上一两句简短解释。",
|
||||
category: "capability",
|
||||
priority: 38,
|
||||
@@ -82,7 +82,7 @@ public enum AIHintLocalCatalog: Sendable {
|
||||
),
|
||||
AIHintCard(
|
||||
id: "local-zh-howto",
|
||||
displayText: "给我一个小技巧",
|
||||
displayText: "生活技巧",
|
||||
prompt: "分享一个实用的生活或工作效率小技巧,用中文说清步骤与适用场景(4-6 句)。",
|
||||
category: "capability",
|
||||
priority: 36,
|
||||
@@ -124,7 +124,7 @@ public enum AIHintLocalCatalog: Sendable {
|
||||
),
|
||||
AIHintCard(
|
||||
id: "local-en-encyclopedia",
|
||||
displayText: "Explain a concept",
|
||||
displayText: "A concept",
|
||||
prompt: "Explain an interesting everyday concept in plain English with one real-life example (4-6 sentences).",
|
||||
category: "capability",
|
||||
priority: 40,
|
||||
@@ -133,7 +133,7 @@ public enum AIHintLocalCatalog: Sendable {
|
||||
),
|
||||
AIHintCard(
|
||||
id: "local-en-stocks",
|
||||
displayText: "Market pulse",
|
||||
displayText: "Markets",
|
||||
prompt: "Summarize today's broad market mood (US or global) in plain English, "
|
||||
+ "note possible drivers, and add this is not financial advice (4-6 sentences).",
|
||||
category: "economy",
|
||||
@@ -153,7 +153,7 @@ public enum AIHintLocalCatalog: Sendable {
|
||||
),
|
||||
AIHintCard(
|
||||
id: "local-en-quote",
|
||||
displayText: "Share a quote",
|
||||
displayText: "A quote",
|
||||
prompt: "Share one short quote worth sending today, plus one or two sentences of context.",
|
||||
category: "capability",
|
||||
priority: 38,
|
||||
@@ -162,7 +162,7 @@ public enum AIHintLocalCatalog: Sendable {
|
||||
),
|
||||
AIHintCard(
|
||||
id: "local-en-howto",
|
||||
displayText: "Give a tip",
|
||||
displayText: "A tip",
|
||||
prompt: "Share one practical life or productivity tip in English, with steps and when it helps (4-6 sentences).",
|
||||
category: "capability",
|
||||
priority: 36,
|
||||
|
||||
@@ -1,34 +1,21 @@
|
||||
// AIHintPool.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Builds the idle carousel pool: 100% clipboard cards while eligible,
|
||||
// otherwise a shuffled mix of non-clipboard local + remote cards.
|
||||
// Builds the idle carousel pool from local + remote cards. Clipboard
|
||||
// sentence cards are excluded: the 30s copy window shows skill chips.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum AIHintPool: Sendable {
|
||||
public static func activeCards(
|
||||
pack: AIHintPack,
|
||||
clipboardHistoryEnabled: Bool,
|
||||
newestClipboard: ClipboardHistoryEntry?,
|
||||
now: Date = Date()
|
||||
pack: AIHintPack
|
||||
) -> [AIHintCard] {
|
||||
let clipboardEligible = clipboardHistoryEnabled
|
||||
&& newestClipboard.map { ClipboardHistoryPolicy.isEligibleForAIHint($0, now: now) } == true
|
||||
|
||||
let clipboardCards = pack.cards.filter(\.requiresClipboard30s)
|
||||
let regularCards = pack.cards.filter { !$0.requiresClipboard30s }
|
||||
.filter { !isHistoricalToday($0) }
|
||||
|
||||
// Within 30s: only clipboard-related sentences.
|
||||
if clipboardEligible {
|
||||
let pool = clipboardCards.isEmpty
|
||||
? AIHintLocalCatalog.cards(locale: pack.locale).filter(\.requiresClipboard30s)
|
||||
: clipboardCards
|
||||
return pool.sorted { $0.priority > $1.priority }
|
||||
}
|
||||
|
||||
// Otherwise: drop clipboard-conditioned cards entirely.
|
||||
// Clipboard-conditioned sentences no longer rotate in the carousel;
|
||||
// the 30s window shows skill chips instead. Keep evergreen content
|
||||
// loaded so leaving the window does not flash a leftover clipboard card.
|
||||
var merged = regularCards
|
||||
let localRegular = AIHintLocalCatalog.cards(locale: pack.locale)
|
||||
.filter { !$0.requiresClipboard30s }
|
||||
@@ -38,6 +25,16 @@ public enum AIHintPool: Sendable {
|
||||
return merged.sorted { $0.priority > $1.priority }
|
||||
}
|
||||
|
||||
/// Copy-then-30s window where clipboard skill chips replace the carousel.
|
||||
public static func isClipboardSkillWindowActive(
|
||||
clipboardHistoryEnabled: Bool,
|
||||
newestClipboard: ClipboardHistoryEntry?,
|
||||
now: Date = Date()
|
||||
) -> Bool {
|
||||
clipboardHistoryEnabled
|
||||
&& newestClipboard.map { ClipboardHistoryPolicy.isEligibleForAIHint($0, now: now) } == true
|
||||
}
|
||||
|
||||
/// Prompt for a tapped card. Clipboard cards fail closed so an expired
|
||||
/// window can never send an instruction without its material.
|
||||
public static func resolvePrompt(
|
||||
|
||||
@@ -110,11 +110,81 @@ public enum FlowKeyboardAdoptBusyPolicy {
|
||||
guard !isAwaitingFlowResult else { return .none }
|
||||
guard let busyId = snapshot.busyUtteranceId else { return .none }
|
||||
guard busyId != lastConsumedUtteranceId else { return .none }
|
||||
guard busyId != lastStoppedUtteranceId else { return .none }
|
||||
return .adoptProcessing(sessionId: sessionId, utteranceId: busyId)
|
||||
default:
|
||||
return .none
|
||||
}
|
||||
}
|
||||
|
||||
/// Host still advertises `processing` for an utterance the keyboard already
|
||||
/// acked, and the result mailbox is empty. That is a leaked gate — not a
|
||||
/// live ASR/LLM wait (those have no ack yet).
|
||||
public static func isStaleDeliveredProcessing(
|
||||
busyUtteranceId: UUID,
|
||||
latestResult: FlowResult?,
|
||||
latestAck: FlowAck?
|
||||
) -> Bool {
|
||||
guard let ack = latestAck, ack.utteranceId == busyUtteranceId else {
|
||||
return false
|
||||
}
|
||||
if latestResult?.utteranceId == busyUtteranceId {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Terminal store after await
|
||||
|
||||
public enum FlowTerminalStorePolicy {
|
||||
/// After an `await`, only the still-current, not-yet-terminal utterance
|
||||
/// may write a final/error payload. Abort during LLM must not deliver.
|
||||
public static func canStore(
|
||||
currentUtteranceId: UUID?,
|
||||
finishedUtteranceId: UUID,
|
||||
alreadyTerminal: Bool
|
||||
) -> Bool {
|
||||
currentUtteranceId == finishedUtteranceId && !alreadyTerminal
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ack must drop a leaked processing gate
|
||||
|
||||
public enum FlowHostAckGatePolicy {
|
||||
/// The keyboard acked this live utterance; the processing flag must not
|
||||
/// outlive that ack (hint-card used to leak `reason=processing`).
|
||||
public static func shouldDropProcessingGate(
|
||||
ackUtteranceId: UUID,
|
||||
currentUtteranceId: UUID?,
|
||||
isUtteranceProcessing: Bool
|
||||
) -> Bool {
|
||||
isUtteranceProcessing && currentUtteranceId == ackUtteranceId
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty double-tap skip
|
||||
|
||||
/// Drop a take before ASR when the press was too short to be speech.
|
||||
public enum FlowEmptyTapSkipPolicy {
|
||||
public static let maxDurationSeconds: TimeInterval = 0.3
|
||||
public static let silencePeakThreshold: Float =
|
||||
FlowCaptureTailDrainPolicy.flowDefault.silenceRMSThreshold
|
||||
|
||||
public static func peakAbs(_ samples: [Float]) -> Float {
|
||||
samples.reduce(Float(0)) { max($0, abs($1)) }
|
||||
}
|
||||
|
||||
/// `sampleCount == 0` or a missing peak counts as silence.
|
||||
public static func shouldSkip(
|
||||
durationSeconds: TimeInterval,
|
||||
sampleCount: Int,
|
||||
peakAmplitude: Float?
|
||||
) -> Bool {
|
||||
guard durationSeconds < maxDurationSeconds else { return false }
|
||||
if sampleCount <= 0 { return true }
|
||||
return (peakAmplitude ?? 0) < silencePeakThreshold
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Result matching
|
||||
|
||||
@@ -139,5 +139,7 @@ public enum FlowSessionKeys {
|
||||
case audioUnavailable
|
||||
case asrFailed
|
||||
case generic
|
||||
/// Short silent tap discarded before ASR — keyboard returns to idle quietly.
|
||||
case discardedEmpty
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,6 +239,8 @@ public final class KeyboardState: ObservableObject {
|
||||
public var sendAIAnswer: () -> Void = {}
|
||||
/// Sends a tapped idle hint card as the AI question (skip microphone).
|
||||
public var submitAIHint: (AIHintCard) -> Void = { _ in }
|
||||
/// Sends a clipboard skill (reply / summarize / translate / future).
|
||||
public var submitAIClipboardSkill: (AIClipboardSkill) -> Void = { _ in }
|
||||
public var openSettings: () -> Void = {}
|
||||
/// Opens the host app straight to input-resource deployment. Used by the
|
||||
/// typing surface when Rime resources have not been deployed yet.
|
||||
@@ -303,8 +305,9 @@ public final class KeyboardState: ObservableObject {
|
||||
surface == .ai && aiSession.isBusy
|
||||
}
|
||||
|
||||
/// Normal dictation can be discarded from initial microphone startup
|
||||
/// through ASR / polish processing. Edit mode owns its separate close flow.
|
||||
/// Normal dictation can be discarded from microphone startup through
|
||||
/// ASR / polish, including the abort-wait after Cancel until the host
|
||||
/// acks (coordinator keeps `phase == .processing` for that window).
|
||||
public var canCancelVoiceInput: Bool {
|
||||
guard !editSession.isActive else { return false }
|
||||
switch phase {
|
||||
@@ -360,6 +363,8 @@ extension KeyboardState.Phase.ErrorKind {
|
||||
return .hostAudioUnavailable
|
||||
case .asrFailed, .generic:
|
||||
return .hostTranscriptionFailed(error.message)
|
||||
case .discardedEmpty:
|
||||
return .noSpeechDetected
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user