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
+46 -4
View File
@@ -1,11 +1,39 @@
// AIHintModels.swift
// OSGKeyboard · Shared
//
// Hint cards for the AI-mode idle carousel. Remote packs use `text`; the
// host compresses that into `displayText` before writing the ready pack.
// Hint cards for the AI-mode idle carousel. Remote packs use `text` plus
// optional `metadata`; the host extracts a keyword `displayText` for the chip.
import Foundation
public struct AIHintMetadata: Codable, Equatable, Sendable {
public var title: String?
public var city: String?
public var tempC: Double?
public var soul: String?
public var name: String?
public var date: String?
public var day: String?
public init(
title: String? = nil,
city: String? = nil,
tempC: Double? = nil,
soul: String? = nil,
name: String? = nil,
date: String? = nil,
day: String? = nil
) {
self.title = title
self.city = city
self.tempC = tempC
self.soul = soul
self.name = name
self.date = date
self.day = day
}
}
public struct AIHintCard: Codable, Equatable, Identifiable, Sendable {
public let id: String
/// One-line carousel label (after host keyword pass, or local catalog).
@@ -17,6 +45,7 @@ public struct AIHintCard: Codable, Equatable, Identifiable, Sendable {
public var source: String
public var locale: String
public var conditions: [String]
public var metadata: AIHintMetadata?
public init(
id: String,
@@ -26,7 +55,8 @@ public struct AIHintCard: Codable, Equatable, Identifiable, Sendable {
priority: Int = 50,
source: String = "local",
locale: String = "zh",
conditions: [String] = []
conditions: [String] = [],
metadata: AIHintMetadata? = nil
) {
self.id = id
self.displayText = displayText
@@ -36,14 +66,24 @@ public struct AIHintCard: Codable, Equatable, Identifiable, Sendable {
self.source = source
self.locale = locale
self.conditions = conditions
self.metadata = metadata
}
public var requiresClipboard30s: Bool {
conditions.contains("clipboard_30s") || category == "clipboard"
}
/// Keyword shown in the idle chip; prefers feed metadata over raw `text`.
public var resolvedDisplayText: String {
AIHintKeywordExtractor.displayText(for: self)
}
public var visualKind: AIHintVisualKind {
AIHintVisualKind.resolve(self)
}
enum CodingKeys: String, CodingKey {
case id, displayText, text, prompt, category, priority, source, locale, conditions
case id, displayText, text, prompt, category, priority, source, locale, conditions, metadata
}
public init(from decoder: Decoder) throws {
@@ -55,6 +95,7 @@ public struct AIHintCard: Codable, Equatable, Identifiable, Sendable {
source = try container.decodeIfPresent(String.self, forKey: .source) ?? "remote"
locale = try container.decodeIfPresent(String.self, forKey: .locale) ?? "zh"
conditions = try container.decodeIfPresent([String].self, forKey: .conditions) ?? []
metadata = try container.decodeIfPresent(AIHintMetadata.self, forKey: .metadata)
if let display = try container.decodeIfPresent(String.self, forKey: .displayText),
!display.isEmpty {
displayText = display
@@ -73,6 +114,7 @@ public struct AIHintCard: Codable, Equatable, Identifiable, Sendable {
try container.encode(source, forKey: .source)
try container.encode(locale, forKey: .locale)
try container.encode(conditions, forKey: .conditions)
try container.encodeIfPresent(metadata, forKey: .metadata)
}
}
@@ -0,0 +1,58 @@
// AIHintVisualKind.swift
// OSGKeyboard · Shared
//
// Maps hint-feed category/source onto the idle-chip SF Symbol.
import Foundation
public enum AIHintVisualKind: String, Equatable, Sendable {
case calendar
case weather
case news
case stocks
case trending
case search
public var systemImage: String {
switch self {
case .calendar: return "calendar"
case .weather: return "cloud.sun.fill"
case .news: return "newspaper.fill"
case .stocks: return "chart.line.uptrend.xyaxis"
case .trending: return "flame.fill"
case .search: return "magnifyingglass"
}
}
public static func resolve(_ card: AIHintCard) -> Self {
let category = card.category.lowercased()
let source = card.source.lowercased()
let id = card.id.lowercased()
if category == "weather" || source.contains("meteo") {
return .weather
}
if category == "economy" || id.contains("stock") {
return .stocks
}
if category == "society"
|| source.contains("open-hot")
|| source.contains("tophub-open") {
return .trending
}
if category == "holiday"
|| category == "history"
|| source.contains("holiday")
|| card.conditions.contains("date") {
return .calendar
}
if category == "daily" {
if card.metadata?.soul != nil || id.contains("soul") {
return .search
}
return .news
}
// Quotes, encyclopedia, how-to, and unknown capability cards.
return .search
}
}
@@ -161,7 +161,7 @@ public struct AISessionState: Equatable, Sendable {
/// replace the previous committed `answer` until `receiveAnswer`.
public mutating func receivePartialAnswer(_ text: String, utteranceID: UUID) {
guard isActive, activeUtteranceID == utteranceID else { return }
if phase == .recognizing {
if phase == .recognizing || phase == .preparing {
phase = .generating
}
guard phase == .generating else { return }
@@ -25,6 +25,45 @@ public struct TranslationLanguage: Identifiable, Hashable, Sendable {
self.promptLanguageName = promptLanguageName
self.nativeName = nativeName
}
/// One-character Chinese token for compact chips such as.
public var chineseShort: String {
switch id {
case "en": return ""
case "zh-Hans": return ""
case "zh-Hant": return ""
case "ja": return ""
case "ko": return ""
case "fr": return ""
case "de": return ""
case "es": return "西"
case "ru": return ""
case "pt": return ""
default: return nativeName
}
}
/// Country-style English code for compact chips such asTo JP.
public var englishShort: String {
switch id {
case "en": return "EN"
case "zh-Hans": return "CN"
case "zh-Hant": return "TW"
case "ja": return "JP"
case "ko": return "KR"
case "fr": return "FR"
case "de": return "DE"
case "es": return "ES"
case "ru": return "RU"
case "pt": return "PT"
default: return id.uppercased()
}
}
/// True when this target is a Chinese script ( or ).
public var isChineseScript: Bool {
id == "zh-Hans" || id == "zh-Hant"
}
}
public enum TranslationLanguageCatalog {
@@ -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 必须单行,不要省略号结尾
- 中文约 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)
}
}
@@ -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,
+16 -19
View File
@@ -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
}
}
}
@@ -36,6 +36,11 @@ public final class FlowUtterancePCMStore: @unchecked Sendable {
lock.withLock { samples.count }
}
/// Copy of accumulated samples without clearing the store.
public func snapshot() -> [Float] {
lock.withLock { samples }
}
/// Returns accumulated samples and clears the store.
public func consume() -> [Float] {
lock.withLock {