2cc81c4628
Add local clipboard intent recommendations, webpage and phone actions, and safer host handoffs. Refine managed gateway, catalog refresh, onboarding, and adaptive polish behavior.
224 lines
7.6 KiB
Swift
224 lines
7.6 KiB
Swift
// AIHintModels.swift
|
|
// OSGKeyboard · Shared
|
|
//
|
|
// 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).
|
|
public var displayText: String
|
|
/// Full user message sent to the AI question LLM on tap.
|
|
public var prompt: String
|
|
public var category: String
|
|
public var priority: Int
|
|
public var source: String
|
|
public var locale: String
|
|
public var conditions: [String]
|
|
public var metadata: AIHintMetadata?
|
|
/// Server policy intent. Ordinary cards match hold-to-talk AI; current
|
|
/// information cards additionally require online search.
|
|
public var taskKind: ManagedGatewayTaskKind
|
|
|
|
public init(
|
|
id: String,
|
|
displayText: String,
|
|
prompt: String,
|
|
category: String,
|
|
priority: Int = 50,
|
|
source: String = "local",
|
|
locale: String = "zh",
|
|
conditions: [String] = [],
|
|
metadata: AIHintMetadata? = nil,
|
|
taskKind: ManagedGatewayTaskKind = .aiQuestion
|
|
) {
|
|
self.id = id
|
|
self.displayText = displayText
|
|
self.prompt = prompt
|
|
self.category = category
|
|
self.priority = priority
|
|
self.source = source
|
|
self.locale = locale
|
|
self.conditions = conditions
|
|
self.metadata = metadata
|
|
self.taskKind = taskKind
|
|
}
|
|
|
|
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, metadata, taskKind
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
id = try container.decode(String.self, forKey: .id)
|
|
prompt = try container.decode(String.self, forKey: .prompt)
|
|
category = try container.decodeIfPresent(String.self, forKey: .category) ?? "general"
|
|
priority = try container.decodeIfPresent(Int.self, forKey: .priority) ?? 50
|
|
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)
|
|
taskKind = try container.decodeIfPresent(
|
|
ManagedGatewayTaskKind.self,
|
|
forKey: .taskKind
|
|
) ?? .aiQuestion
|
|
if let display = try container.decodeIfPresent(String.self, forKey: .displayText),
|
|
!display.isEmpty {
|
|
displayText = display
|
|
} else {
|
|
displayText = try container.decodeIfPresent(String.self, forKey: .text) ?? ""
|
|
}
|
|
}
|
|
|
|
public func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(id, forKey: .id)
|
|
try container.encode(displayText, forKey: .displayText)
|
|
try container.encode(prompt, forKey: .prompt)
|
|
try container.encode(category, forKey: .category)
|
|
try container.encode(priority, forKey: .priority)
|
|
try container.encode(source, forKey: .source)
|
|
try container.encode(locale, forKey: .locale)
|
|
try container.encode(conditions, forKey: .conditions)
|
|
try container.encodeIfPresent(metadata, forKey: .metadata)
|
|
try container.encode(taskKind, forKey: .taskKind)
|
|
}
|
|
}
|
|
|
|
public struct AIHintPack: Codable, Equatable, Sendable {
|
|
public var locale: String
|
|
public var generatedAt: String?
|
|
public var expiresAt: String?
|
|
public var version: Int
|
|
public var cards: [AIHintCard]
|
|
/// Wall-clock when the host last successfully wrote this ready pack.
|
|
public var refreshedAt: Date?
|
|
|
|
public init(
|
|
locale: String,
|
|
generatedAt: String? = nil,
|
|
expiresAt: String? = nil,
|
|
version: Int = 1,
|
|
cards: [AIHintCard] = [],
|
|
refreshedAt: Date? = nil
|
|
) {
|
|
self.locale = locale
|
|
self.generatedAt = generatedAt
|
|
self.expiresAt = expiresAt
|
|
self.version = version
|
|
self.cards = cards
|
|
self.refreshedAt = refreshedAt
|
|
}
|
|
}
|
|
|
|
public struct AIHintManifest: Codable, Equatable, Sendable {
|
|
public var generatedAt: String?
|
|
public var expiresAt: String?
|
|
public var intervalHours: Int?
|
|
public var locales: [String]?
|
|
public var files: [String: String?]?
|
|
|
|
public init(
|
|
generatedAt: String? = nil,
|
|
expiresAt: String? = nil,
|
|
intervalHours: Int? = nil,
|
|
locales: [String]? = nil,
|
|
files: [String: String?]? = nil
|
|
) {
|
|
self.generatedAt = generatedAt
|
|
self.expiresAt = expiresAt
|
|
self.intervalHours = intervalHours
|
|
self.locales = locales
|
|
self.files = files
|
|
}
|
|
}
|
|
|
|
public enum AIHintFeedEndpoints {
|
|
public static let baseURL = URL(string: "https://account.osglab.com/v1/content/hints")!
|
|
public static let manifestURL = baseURL.appendingPathComponent("manifest")
|
|
/// Packs the app fetches and the keyboard can resolve.
|
|
public static let supportedLocales = ["zh", "en"]
|
|
|
|
public static func packURL(locale: String) -> URL {
|
|
baseURL.appendingPathComponent(locale)
|
|
}
|
|
}
|
|
|
|
public enum AIHintLocaleResolver {
|
|
/// Only `zh-Hans` uses the Chinese pack; everything else uses English.
|
|
public static func packLocale(
|
|
preferredLanguages: [String] = Locale.preferredLanguages
|
|
) -> String {
|
|
let primary = (preferredLanguages.first ?? "").lowercased()
|
|
if primary == "zh-hans" || primary.hasPrefix("zh-hans-") || primary.hasPrefix("zh-hans_") {
|
|
return "zh"
|
|
}
|
|
return "en"
|
|
}
|
|
}
|
|
|
|
public enum AIHintAppGroupKeys {
|
|
public static let readyPackPrefix = "hints.ready."
|
|
public static let lastSuccessPrefix = "hints.meta.lastSuccessAt."
|
|
public static let lastAttemptAt = "hints.meta.lastAttemptAt"
|
|
public static let manifest = "hints.meta.manifest.v1"
|
|
public static let manifestETag = "hints.meta.manifestETag.v1"
|
|
public static let packETagPrefix = "hints.meta.packETag."
|
|
|
|
public static func readyPackKey(locale: String) -> String {
|
|
readyPackPrefix + locale
|
|
}
|
|
|
|
/// Freshness is tracked per locale so a zh success cannot mask an en failure.
|
|
public static func lastSuccessKey(locale: String) -> String {
|
|
lastSuccessPrefix + locale
|
|
}
|
|
|
|
public static func packETagKey(locale: String) -> String {
|
|
packETagPrefix + locale
|
|
}
|
|
}
|