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:
@@ -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 as「To 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 {
|
||||
|
||||
Reference in New Issue
Block a user