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 {