feat: macOS architecture, cloud ASR/LLM providers, and 6-step iOS onboarding
- Add macOS menu-bar dictation app with local ASR models (SenseVoice/Qwen3),
global Option hotkey, and bottom overlay
- Add cloud ASR/LLM providers (Anthropic, Volcengine, Bailian, and more) with
provider logos, model listing, and connection checks
- Add shared 7-day usage stats UI (UsageStatsCluster / SevenDayUsageChart)
- Add iOS onboarding step 6 for polish LLM setup; hide custom-language-model
diagnostic toggle behind DEBUG
- Unify iOS onboarding tagline with the macOS brand line ("开口即文字。")
- Rewrite README (Chinese-first, product-oriented) and refresh GitHub Pages
This commit is contained in:
@@ -8,6 +8,11 @@
|
||||
import Foundation
|
||||
|
||||
public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
/// Default polish LLM for fresh installs (local + cloud pickers).
|
||||
public static let defaultPolishProviderId = "deepseek"
|
||||
/// Default cloud ASR provider for fresh installs (independent from polish).
|
||||
public static let defaultCloudASRProviderId = "volcengine"
|
||||
|
||||
// MARK: - Keys
|
||||
|
||||
public enum Keys {
|
||||
@@ -31,6 +36,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
public static let handednessPreference = "config.handednessPreference"
|
||||
public static let cursorDragNavigationEnabled = "config.cursorDragNavigationEnabled"
|
||||
public static let polishIntensity = "config.polishIntensity"
|
||||
public static let llmThinkingEnabled = "config.llmThinkingEnabled"
|
||||
public static let detectedAppContext = "config.detectedAppContext"
|
||||
public static let detectedAppContextAt = "config.detectedAppContextAt"
|
||||
public static let personalDictionary = "config.personalDictionary.v1"
|
||||
@@ -70,6 +76,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
public var handednessPreference: HandednessPreference
|
||||
public var cursorDragNavigationEnabled: Bool
|
||||
public var polishIntensity: PolishIntensity
|
||||
/// Enables provider-specific reasoning / thinking controls for polish LLM requests.
|
||||
public var llmThinkingEnabled: Bool
|
||||
public var personalDictionary: PersonalDictionary
|
||||
/// Opt-in iCloud KVS sync for the personal dictionary (main app only).
|
||||
public var personalDictionaryICloudSyncEnabled: Bool
|
||||
@@ -150,7 +158,9 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
OpenAICompatibleClient(
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
model: model
|
||||
model: model,
|
||||
providerId: providerId,
|
||||
thinkingEnabled: llmThinkingEnabled
|
||||
)
|
||||
}
|
||||
|
||||
@@ -193,8 +203,9 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
|
||||
/// Loads configuration from a known-available UserDefaults suite.
|
||||
public static func load(fromAvailable defaults: UserDefaults) -> AppGroupConfiguration {
|
||||
let storedProviderId = defaults.string(forKey: Keys.providerId)
|
||||
var config = AppGroupConfiguration(
|
||||
providerId: defaults.string(forKey: Keys.providerId) ?? "openai",
|
||||
providerId: storedProviderId ?? defaultPolishProviderId,
|
||||
baseURL: "",
|
||||
model: "",
|
||||
asrProviderId: defaults.string(forKey: Keys.asrProviderId) ?? "",
|
||||
@@ -228,6 +239,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
return defaults.bool(forKey: Keys.cursorDragNavigationEnabled)
|
||||
}(),
|
||||
polishIntensity: resolvePolishIntensity(from: defaults),
|
||||
llmThinkingEnabled: defaults.bool(forKey: Keys.llmThinkingEnabled),
|
||||
personalDictionary: decodePersonalDictionary(from: defaults),
|
||||
personalDictionaryICloudSyncEnabled: {
|
||||
if defaults.object(forKey: Keys.personalDictionaryICloudSyncEnabled) == nil {
|
||||
@@ -267,7 +279,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
}
|
||||
|
||||
if config.asrProviderId.isEmpty {
|
||||
config.asrProviderId = config.providerId
|
||||
// Pre-split installs only stored `providerId`; copy it so ASR keeps working.
|
||||
config.asrProviderId = storedProviderId ?? defaultCloudASRProviderId
|
||||
defaults.set(config.asrProviderId, forKey: Keys.asrProviderId)
|
||||
}
|
||||
let asrPreset = LLMProvider.provider(id: config.asrProviderId)
|
||||
@@ -279,6 +292,17 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
?? CloudASRModelCatalog.defaultModel(for: config.asrProviderId)
|
||||
}
|
||||
|
||||
// Legacy qwen cloud ASR → bailian realtime (HTTP Flash path removed).
|
||||
if config.asrProviderId == "qwen" {
|
||||
let bailian = LLMProvider.provider(id: "bailian")
|
||||
config.asrProviderId = "bailian"
|
||||
config.asrBaseURL = bailian.defaultBaseURL
|
||||
config.asrModel = CloudASRModelCatalog.alibabaFunASRRealtime
|
||||
defaults.set(config.asrProviderId, forKey: Keys.asrProviderId)
|
||||
defaults.set(config.asrBaseURL, forKey: Keys.asrBaseURL)
|
||||
defaults.set(config.asrModel, forKey: Keys.asrModel)
|
||||
}
|
||||
|
||||
// One-shot legacy migration: plaintext apiKey in UserDefaults → Keychain.
|
||||
_ = resolveAPIKey(
|
||||
defaults: defaults,
|
||||
@@ -311,26 +335,6 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
config.modeId = "polish"
|
||||
defaults.set("polish", forKey: Keys.modeId)
|
||||
}
|
||||
// DeepSeek is local-engine only — never a cloud picker choice.
|
||||
if config.engineMode == "cloud", config.providerId == "deepseek" {
|
||||
let openAI = LLMProvider.provider(id: "openai")
|
||||
config.providerId = openAI.id
|
||||
config.baseURL = openAI.defaultBaseURL
|
||||
config.model = openAI.defaultModel
|
||||
defaults.set(openAI.id, forKey: Keys.providerId)
|
||||
defaults.set(openAI.defaultBaseURL, forKey: Keys.baseURL)
|
||||
defaults.set(openAI.defaultModel, forKey: Keys.model)
|
||||
}
|
||||
if config.engineMode == "cloud", config.asrProviderId == "deepseek" {
|
||||
let openAI = LLMProvider.provider(id: "openai")
|
||||
config.asrProviderId = openAI.id
|
||||
config.asrBaseURL = openAI.defaultBaseURL
|
||||
config.asrModel = CloudASRModelCatalog.defaultModel(for: openAI.id)
|
||||
defaults.set(openAI.id, forKey: Keys.asrProviderId)
|
||||
defaults.set(openAI.defaultBaseURL, forKey: Keys.asrBaseURL)
|
||||
defaults.set(openAI.defaultModel, forKey: Keys.asrModel)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
@@ -352,6 +356,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
defaults.set(handednessPreference.rawValue, forKey: Keys.handednessPreference)
|
||||
defaults.set(cursorDragNavigationEnabled, forKey: Keys.cursorDragNavigationEnabled)
|
||||
defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity)
|
||||
defaults.set(llmThinkingEnabled, forKey: Keys.llmThinkingEnabled)
|
||||
defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch)
|
||||
defaults.set(flowInactivityDuration.rawValue, forKey: Keys.flowInactivityDuration)
|
||||
defaults.set(localASRCustomLanguageModelEnabled, forKey: Keys.localASRCustomLanguageModelEnabled)
|
||||
|
||||
@@ -10,10 +10,14 @@ import Foundation
|
||||
public enum CloudASRStrategy: String, Sendable, Equatable {
|
||||
/// 智谱 GLM-ASR — `hotwords` + optional `prompt`.
|
||||
case zhipuHotwords
|
||||
/// 阿里百炼 Fun-ASR — managed `vocabulary_id` + context text.
|
||||
case alibabaVocabulary
|
||||
/// OpenAI / 小米 MiMo / 自定义端点 — `prompt` on transcription APIs.
|
||||
/// 百炼 Fun-ASR Realtime — DashScope 经典 inference WebSocket 流式。
|
||||
case bailianStreaming
|
||||
/// OpenAI / Groq / 硅基流动 / Whisper 等 — `prompt` on transcription APIs.
|
||||
case prompt
|
||||
/// OpenRouter `/audio/transcriptions` — JSON body + base64 WAV (not multipart).
|
||||
case openRouterJson
|
||||
/// 火山引擎 SAUC 大模型流式 ASR(WebSocket + binary frame)。
|
||||
case volcengineStreaming
|
||||
/// Moonshot 托管 API 暂无音频转写;云端引擎回退端侧 ASR。
|
||||
case localFallback
|
||||
}
|
||||
@@ -54,8 +58,23 @@ public enum CloudASRError: Error, LocalizedError, Sendable, Equatable {
|
||||
}
|
||||
|
||||
public enum CloudASRModelCatalog {
|
||||
/// Provider ids shown in the cloud ASR picker (explicit allowlist).
|
||||
public static let selectableProviderIds: Set<String> = [
|
||||
"openai",
|
||||
"whisper",
|
||||
"bailian",
|
||||
"zhipu",
|
||||
"groq",
|
||||
"siliconflow",
|
||||
"openrouter",
|
||||
"mimo",
|
||||
"volcengine",
|
||||
"custom",
|
||||
]
|
||||
|
||||
/// Sync Fun-ASR Flash — base64 upload, ≤ 5 min, supports context + vocabulary.
|
||||
public static let alibabaFunASRFlash = "fun-asr-flash-2026-06-15"
|
||||
public static let alibabaFunASRRealtime = "fun-asr-realtime"
|
||||
/// Must match the ASR model used at recognition time.
|
||||
public static let alibabaVocabularyTargetModel = alibabaFunASRFlash
|
||||
|
||||
@@ -63,24 +82,38 @@ public enum CloudASRModelCatalog {
|
||||
public static let openAITranscribe = "gpt-4o-mini-transcribe"
|
||||
public static let openAIWhisper = "whisper-1"
|
||||
public static let mimoASR = "mimo-v2.5-asr"
|
||||
public static let groqWhisper = "whisper-large-v3-turbo"
|
||||
public static let siliconflowASR = "FunAudioLLM/SenseVoiceSmall"
|
||||
public static let openrouterWhisper = "openai/whisper-large-v3-turbo"
|
||||
public static let volcengineDefaultResourceID = "volc.seedasr.sauc.duration"
|
||||
public static let volcengineEndpoint = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async"
|
||||
public static let bailianDefaultEndpoint = "wss://dashscope.aliyuncs.com/api-ws/v1/inference/"
|
||||
|
||||
public static let alibabaAPIBase = "https://dashscope.aliyuncs.com/api/v1"
|
||||
public static let alibabaCustomizationPath = "/services/audio/asr/customization"
|
||||
public static let alibabaMultimodalPath = "/services/aigc/multimodal-generation/generation"
|
||||
public static let zhipuTranscriptionPath = "/audio/transcriptions"
|
||||
|
||||
public static func supportsCloudASRSelection(providerId: String) -> Bool {
|
||||
selectableProviderIds.contains(providerId)
|
||||
}
|
||||
|
||||
public static func strategy(for providerId: String) -> CloudASRStrategy {
|
||||
switch providerId {
|
||||
case "zhipu":
|
||||
return .zhipuHotwords
|
||||
case "qwen":
|
||||
return .alibabaVocabulary
|
||||
case "bailian":
|
||||
return .bailianStreaming
|
||||
case "moonshot":
|
||||
return .localFallback
|
||||
case "openai", "mimo", "custom":
|
||||
case "volcengine":
|
||||
return .volcengineStreaming
|
||||
case "openrouter":
|
||||
return .openRouterJson
|
||||
case "openai", "whisper", "mimo", "groq", "siliconflow", "custom":
|
||||
return .prompt
|
||||
default:
|
||||
return .prompt
|
||||
return .localFallback
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,16 +121,36 @@ public enum CloudASRModelCatalog {
|
||||
switch providerId {
|
||||
case "zhipu":
|
||||
return zhipuGLMASR
|
||||
case "qwen":
|
||||
return alibabaFunASRFlash
|
||||
case "bailian":
|
||||
return alibabaFunASRRealtime
|
||||
case "whisper":
|
||||
return openAIWhisper
|
||||
case "mimo":
|
||||
return mimoASR
|
||||
case "groq":
|
||||
return groqWhisper
|
||||
case "siliconflow":
|
||||
return siliconflowASR
|
||||
case "openrouter":
|
||||
return openrouterWhisper
|
||||
case "volcengine":
|
||||
return volcengineDefaultResourceID
|
||||
case "openai", "custom":
|
||||
return openAITranscribe
|
||||
default:
|
||||
return openAITranscribe
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the ASR settings card should expose a custom endpoint field.
|
||||
public static func showsASREndpointField(for providerId: String) -> Bool {
|
||||
switch strategy(for: providerId) {
|
||||
case .prompt, .openRouterJson, .bailianStreaming:
|
||||
return true
|
||||
case .zhipuHotwords, .volcengineStreaming, .localFallback:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension LLMProvider {
|
||||
@@ -112,9 +165,9 @@ extension LLMProvider {
|
||||
/// Official hotwords / vocabulary APIs during cloud ASR (not prompt-only bias).
|
||||
public var supportsPersonalDictionaryCloudASR: Bool {
|
||||
switch cloudASRStrategy {
|
||||
case .zhipuHotwords, .alibabaVocabulary:
|
||||
case .zhipuHotwords:
|
||||
return true
|
||||
case .prompt, .localFallback:
|
||||
case .bailianStreaming, .prompt, .openRouterJson, .volcengineStreaming, .localFallback:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// FlowHandoffPolicy.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Pure decision helpers for keyboard → host handoff. Keeps "session still
|
||||
// alive, ready contract briefly missing" from being treated as a cold start.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Action the keyboard should take when the user presses the mic.
|
||||
public enum FlowMicPressAction: Equatable, Sendable {
|
||||
case startRecording
|
||||
/// Session is alive (or was very recently); poll for ready, then optionally record.
|
||||
case waitForHostReady(recordWhenReady: Bool)
|
||||
/// Host process is gone / no session — open `osgkeyboard://startflow`.
|
||||
case openHostColdStart
|
||||
case ignore
|
||||
}
|
||||
|
||||
/// Whether the host app should show the cold-start overlay for a `startflow`.
|
||||
public enum FlowColdStartOverlayDecision: Equatable, Sendable {
|
||||
/// Do not set handoff flags or show preparing/ready UI.
|
||||
case silence
|
||||
/// Show preparing and run the cold-start / recovery path.
|
||||
case present
|
||||
}
|
||||
|
||||
public enum FlowHandoffPolicy {
|
||||
/// Proactive keyboard auto-launch of the host is intentionally disabled.
|
||||
/// Opening the host must be driven by an explicit mic press (or Live Activity).
|
||||
public static let allowsProactiveHostAutoLaunch = false
|
||||
|
||||
/// Samples of "host truly dead" required before a cold-start jump is allowed
|
||||
/// from a non-press path. Mic press uses `shouldOpenHostColdStart` directly.
|
||||
public static let coldStartDeadSampleThreshold = 2
|
||||
|
||||
/// True when the session contract still implies a living (or recoverable)
|
||||
/// host — so a transient `ready=false` must wait, not jump.
|
||||
public static func shouldTreatHostAsAlive(
|
||||
sessionActive: Bool,
|
||||
hostReachable: Bool,
|
||||
hostStale: Bool,
|
||||
withinReadyGrace: Bool
|
||||
) -> Bool {
|
||||
if hostStale { return false }
|
||||
guard sessionActive else { return false }
|
||||
// Reachable heartbeat, or a recent ready sample, means the process is
|
||||
// still ours — finalize races often look like hostNotReady for one frame.
|
||||
if hostReachable || withinReadyGrace { return true }
|
||||
// Session flag still valid and not past the zombie window: prefer wait.
|
||||
return true
|
||||
}
|
||||
|
||||
/// Whether `osgkeyboard://startflow` is justified for the current host state.
|
||||
public static func shouldOpenHostColdStart(
|
||||
sessionActive: Bool,
|
||||
hostReachable: Bool,
|
||||
hostStale: Bool,
|
||||
withinReadyGrace: Bool
|
||||
) -> Bool {
|
||||
!shouldTreatHostAsAlive(
|
||||
sessionActive: sessionActive,
|
||||
hostReachable: hostReachable,
|
||||
hostStale: hostStale,
|
||||
withinReadyGrace: withinReadyGrace
|
||||
)
|
||||
}
|
||||
|
||||
/// Mic-press routing shared by the keyboard coordinator and unit tests.
|
||||
public static func micPressAction(
|
||||
availability: MicVoiceAvailability,
|
||||
sessionActive: Bool,
|
||||
hostReachable: Bool,
|
||||
hostStale: Bool,
|
||||
withinReadyGrace: Bool
|
||||
) -> FlowMicPressAction {
|
||||
switch availability {
|
||||
case .ready:
|
||||
return .startRecording
|
||||
case .recording, .processing:
|
||||
return .ignore
|
||||
case .unavailable(.missingAPIKey),
|
||||
.unavailable(.noFullAccess),
|
||||
.unavailable(.appGroupUnavailable):
|
||||
// Caller surfaces the specific error UI.
|
||||
return .ignore
|
||||
case .unavailable(.preparingSession):
|
||||
// Session is warming — never cold-start; wait then record.
|
||||
return .waitForHostReady(recordWhenReady: true)
|
||||
case .unavailable(.hostNotReady):
|
||||
if shouldTreatHostAsAlive(
|
||||
sessionActive: sessionActive,
|
||||
hostReachable: hostReachable,
|
||||
hostStale: hostStale,
|
||||
withinReadyGrace: withinReadyGrace
|
||||
) {
|
||||
return .waitForHostReady(recordWhenReady: true)
|
||||
}
|
||||
return .openHostColdStart
|
||||
}
|
||||
}
|
||||
|
||||
/// Host-app gate: a `startflow` against an already-healthy (or busy) session
|
||||
/// must not flash "Voice is ready".
|
||||
public static func coldStartOverlayDecision(
|
||||
sessionIsActive: Bool,
|
||||
hostIsReady: Bool,
|
||||
isUtteranceBusy: Bool
|
||||
) -> FlowColdStartOverlayDecision {
|
||||
guard sessionIsActive else { return .present }
|
||||
if hostIsReady || isUtteranceBusy { return .silence }
|
||||
// Active but not ready and not busy — engine recovery may need UI.
|
||||
return .present
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts consecutive "host truly dead" observations to ignore single-frame races.
|
||||
public struct FlowColdStartDebouncer: Equatable, Sendable {
|
||||
public private(set) var consecutiveDeadSamples: Int = 0
|
||||
|
||||
public init(consecutiveDeadSamples: Int = 0) {
|
||||
self.consecutiveDeadSamples = consecutiveDeadSamples
|
||||
}
|
||||
|
||||
/// Returns true once enough consecutive dead samples have been seen.
|
||||
public mutating func observe(hostTrulyDead: Bool) -> Bool {
|
||||
if hostTrulyDead {
|
||||
consecutiveDeadSamples += 1
|
||||
} else {
|
||||
consecutiveDeadSamples = 0
|
||||
}
|
||||
return consecutiveDeadSamples >= FlowHandoffPolicy.coldStartDeadSampleThreshold
|
||||
}
|
||||
|
||||
public mutating func reset() {
|
||||
consecutiveDeadSamples = 0
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Which hand the user holds the phone with — controls bottom-row key order
|
||||
// on the keyboard (delete ↔ return swap for right-handed use).
|
||||
// on the keyboard (delete ↔ space swap for right-handed use).
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -19,7 +19,7 @@ public enum HandednessPreference: String, CaseIterable, Identifiable, Sendable,
|
||||
}
|
||||
}
|
||||
|
||||
/// Right-handed preference places return on the left and delete on the right.
|
||||
/// Right-handed preference places space on the left and delete on the right.
|
||||
public var swapsActionKeys: Bool { self == .right }
|
||||
|
||||
public static func fromStored(_ raw: String?) -> HandednessPreference {
|
||||
|
||||
@@ -48,15 +48,21 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
||||
apiKeyURL: URL(string: "https://platform.openai.com/api-keys"),
|
||||
blurb: "GPT-4o mini · 多语言 · Multilingual"
|
||||
),
|
||||
.init(
|
||||
id: "ark",
|
||||
name: "火山方舟 Ark",
|
||||
defaultBaseURL: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
defaultModel: "deepseek-v3-2-251201",
|
||||
apiKeyURL: URL(string: "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey"),
|
||||
blurb: "豆包 / DeepSeek · OpenAI 兼容 · OpenAI-compatible"
|
||||
),
|
||||
.init(
|
||||
id: "deepseek",
|
||||
name: "DeepSeek",
|
||||
defaultBaseURL: "https://api.deepseek.com/v1",
|
||||
defaultModel: "deepseek-v4-flash",
|
||||
apiKeyURL: URL(string: "https://platform.deepseek.com/api_keys"),
|
||||
blurb: "deepseek-v4-flash · 本地引擎内置 · Local engine built-in",
|
||||
// Local engine only — never shown in cloud-engine pickers.
|
||||
isUserSelectable: false
|
||||
blurb: "deepseek-v4-flash · 本地引擎可内置 · Local engine optional built-in"
|
||||
),
|
||||
.init(
|
||||
id: "qwen",
|
||||
@@ -82,6 +88,30 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
||||
apiKeyURL: URL(string: "https://platform.moonshot.cn/console/api-keys"),
|
||||
blurb: "Kimi · 长上下文 · Long context"
|
||||
),
|
||||
.init(
|
||||
id: "siliconflow",
|
||||
name: "硅基流动 SiliconFlow",
|
||||
defaultBaseURL: "https://api.siliconflow.cn/v1",
|
||||
defaultModel: "Qwen/Qwen2.5-7B-Instruct",
|
||||
apiKeyURL: URL(string: "https://cloud.siliconflow.cn/account/ak"),
|
||||
blurb: "多模型聚合 · OpenAI 兼容 · OpenAI-compatible"
|
||||
),
|
||||
.init(
|
||||
id: "groq",
|
||||
name: "Groq",
|
||||
defaultBaseURL: "https://api.groq.com/openai/v1",
|
||||
defaultModel: "llama-3.3-70b-versatile",
|
||||
apiKeyURL: URL(string: "https://console.groq.com/keys"),
|
||||
blurb: "超低延迟 LPU · Ultra-low latency"
|
||||
),
|
||||
.init(
|
||||
id: "minimax",
|
||||
name: "MiniMax",
|
||||
defaultBaseURL: "https://api.minimaxi.com/v1",
|
||||
defaultModel: "MiniMax-M2.5",
|
||||
apiKeyURL: URL(string: "https://platform.minimaxi.com/user-center/basic-information"),
|
||||
blurb: "MiniMax-M2.5 · 中文优化 · Chinese-optimized"
|
||||
),
|
||||
.init(
|
||||
id: "mimo",
|
||||
name: "小米 MiMo",
|
||||
@@ -90,6 +120,106 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
||||
apiKeyURL: URL(string: "https://platform.xiaomimimo.com"),
|
||||
blurb: "mimo-v2.5 · 中文优化 · Chinese-optimized"
|
||||
),
|
||||
.init(
|
||||
id: "openrouter",
|
||||
name: "OpenRouter",
|
||||
defaultBaseURL: "https://openrouter.ai/api/v1",
|
||||
defaultModel: "qwen/qwen3-coder:free",
|
||||
apiKeyURL: URL(string: "https://openrouter.ai/keys"),
|
||||
blurb: "多模型路由 · Model routing · OpenAI-compatible"
|
||||
),
|
||||
.init(
|
||||
id: "gemini",
|
||||
name: "Google Gemini",
|
||||
defaultBaseURL: "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
defaultModel: "gemini-2.5-flash",
|
||||
apiKeyURL: URL(string: "https://aistudio.google.com/apikey"),
|
||||
blurb: "Gemini 2.5 Flash · OpenAI 兼容端点"
|
||||
),
|
||||
.init(
|
||||
id: "anthropic",
|
||||
name: "Anthropic Claude",
|
||||
defaultBaseURL: "https://api.anthropic.com/v1",
|
||||
defaultModel: "claude-sonnet-4-6",
|
||||
apiKeyURL: URL(string: "https://console.anthropic.com/settings/keys"),
|
||||
blurb: "Claude Sonnet · Messages API"
|
||||
),
|
||||
.init(
|
||||
id: "xai",
|
||||
name: "xAI Grok",
|
||||
defaultBaseURL: "https://api.x.ai/v1",
|
||||
defaultModel: "grok-3-mini",
|
||||
apiKeyURL: URL(string: "https://console.x.ai"),
|
||||
blurb: "Grok · OpenAI 兼容 · OpenAI-compatible"
|
||||
),
|
||||
.init(
|
||||
id: "mistral",
|
||||
name: "Mistral AI",
|
||||
defaultBaseURL: "https://api.mistral.ai/v1",
|
||||
defaultModel: "mistral-small-latest",
|
||||
apiKeyURL: URL(string: "https://console.mistral.ai/api-keys"),
|
||||
blurb: "Mistral Small · 欧洲托管 · EU-hosted"
|
||||
),
|
||||
.init(
|
||||
id: "cometapi",
|
||||
name: "CometAPI",
|
||||
defaultBaseURL: "https://api.cometapi.com/v1",
|
||||
defaultModel: "gpt-4o",
|
||||
apiKeyURL: URL(string: "https://api.cometapi.com"),
|
||||
blurb: "多模型聚合 · OpenAI 兼容"
|
||||
),
|
||||
.init(
|
||||
id: "alibabaCoding",
|
||||
name: "阿里 Coding Plan",
|
||||
defaultBaseURL: "https://coding-intl.dashscope.aliyuncs.com/v1",
|
||||
defaultModel: "qwen3-coder-plus",
|
||||
apiKeyURL: URL(string: "https://dashscope.console.aliyun.com/apiKey"),
|
||||
blurb: "通义 Coder · 代码润色 · Coding polish"
|
||||
),
|
||||
.init(
|
||||
id: "codingPlanX",
|
||||
name: "CodingPlanX",
|
||||
defaultBaseURL: "https://api.codingplanx.ai/v1",
|
||||
defaultModel: "gpt-5-mini",
|
||||
apiKeyURL: URL(string: "https://codingplanx.ai"),
|
||||
blurb: "CodingPlanX · OpenAI 兼容"
|
||||
),
|
||||
// MARK: - ASR-only presets (hidden from polish picker)
|
||||
.init(
|
||||
id: "volcengine",
|
||||
name: "火山引擎 Volcengine",
|
||||
defaultBaseURL: "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async",
|
||||
defaultModel: "volc.seedasr.sauc.duration",
|
||||
apiKeyURL: URL(string: "https://console.volcengine.com/speech"),
|
||||
blurb: "流式大模型 ASR · API Key 填 appId:accessToken[:resourceId]",
|
||||
isUserSelectable: false
|
||||
),
|
||||
.init(
|
||||
id: "bailian",
|
||||
name: "百炼实时 ASR",
|
||||
defaultBaseURL: "wss://dashscope.aliyuncs.com/api-ws/v1/inference/",
|
||||
defaultModel: "fun-asr-realtime",
|
||||
apiKeyURL: URL(string: "https://dashscope.console.aliyun.com/apiKey"),
|
||||
blurb: "Fun-ASR Realtime · 百炼词表",
|
||||
isUserSelectable: false
|
||||
),
|
||||
.init(
|
||||
id: "whisper",
|
||||
name: "Whisper (OpenAI)",
|
||||
defaultBaseURL: "https://api.openai.com/v1",
|
||||
defaultModel: "whisper-1",
|
||||
apiKeyURL: URL(string: "https://platform.openai.com/api-keys"),
|
||||
blurb: "whisper-1 · 经典 Whisper 端点",
|
||||
isUserSelectable: false
|
||||
),
|
||||
.init(
|
||||
id: "codex_oauth",
|
||||
name: "Codex OAuth",
|
||||
defaultBaseURL: "",
|
||||
defaultModel: "gpt-5.3-codex-spark",
|
||||
blurb: "ChatGPT Codex OAuth · 暂不支持",
|
||||
isUserSelectable: false
|
||||
),
|
||||
.init(
|
||||
id: "custom",
|
||||
name: "Custom · 自定义",
|
||||
@@ -103,16 +233,13 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
||||
presets.first(where: { $0.id == id }) ?? .presets[0]
|
||||
}
|
||||
|
||||
/// Presets the user may pick in Settings / onboarding. DeepSeek is
|
||||
/// excluded — it is wired exclusively to the local engine.
|
||||
/// Presets the user may pick in Settings / onboarding.
|
||||
public static var userSelectablePresets: [LLMProvider] {
|
||||
presets.filter(\.isUserSelectable)
|
||||
}
|
||||
|
||||
/// Cloud ASR presets (excludes providers without a cloud transcription API).
|
||||
/// Cloud ASR presets (explicit allowlist — polish-only providers excluded).
|
||||
public static var asrSelectablePresets: [LLMProvider] {
|
||||
userSelectablePresets.filter {
|
||||
CloudASRModelCatalog.strategy(for: $0.id) != .localFallback
|
||||
}
|
||||
presets.filter { CloudASRModelCatalog.supportsCloudASRSelection(providerId: $0.id) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
didSet {
|
||||
guard !isApplyingConfiguration, engineMode != configuration.engineMode else { return }
|
||||
configuration.engineMode = engineMode
|
||||
applyEngineModeSideEffects()
|
||||
persistConfiguration(postConfigChanged: true)
|
||||
}
|
||||
}
|
||||
@@ -179,7 +178,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
/// Which hand the user holds the phone with — mirrors to the keyboard
|
||||
/// extension so delete / return can swap on the bottom row.
|
||||
/// extension so delete / space can swap on the bottom row.
|
||||
@Published public var handednessPreference: HandednessPreference {
|
||||
didSet {
|
||||
guard !isApplyingConfiguration,
|
||||
@@ -218,6 +217,17 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Enables provider-specific reasoning / thinking controls when the
|
||||
/// selected polish LLM supports them.
|
||||
@Published public var llmThinkingEnabled: Bool {
|
||||
didSet {
|
||||
guard !isApplyingConfiguration,
|
||||
llmThinkingEnabled != configuration.llmThinkingEnabled else { return }
|
||||
configuration.llmThinkingEnabled = llmThinkingEnabled
|
||||
persistConfiguration(postConfigChanged: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// When enabled, the host app tries to return to the source app after a cold-start handoff.
|
||||
@Published public var flowSkipAppSwitch: Bool {
|
||||
didSet {
|
||||
@@ -335,6 +345,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
handednessPreference = configuration.handednessPreference
|
||||
cursorDragNavigationEnabled = configuration.cursorDragNavigationEnabled
|
||||
polishIntensity = configuration.polishIntensity
|
||||
llmThinkingEnabled = configuration.llmThinkingEnabled
|
||||
flowSkipAppSwitch = configuration.flowSkipAppSwitch
|
||||
flowInactivityDuration = configuration.flowInactivityDuration
|
||||
localASRCustomLanguageModelEnabled = configuration.localASRCustomLanguageModelEnabled
|
||||
@@ -347,15 +358,34 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
isApplyingConfiguration = false
|
||||
}
|
||||
|
||||
/// Keep cloud vs local provider choices isolated when the user
|
||||
/// switches engines in Settings / onboarding.
|
||||
private func applyEngineModeSideEffects() {
|
||||
if engineMode == "cloud", providerId == "deepseek" {
|
||||
apply(preset: LLMProvider.provider(id: "openai"))
|
||||
}
|
||||
if engineMode == "cloud", asrProviderId == "deepseek" {
|
||||
applyAsr(preset: LLMProvider.provider(id: "openai"))
|
||||
}
|
||||
public func reset() {
|
||||
isApplyingConfiguration = true
|
||||
let polishPreset = LLMProvider.provider(id: AppGroupConfiguration.defaultPolishProviderId)
|
||||
let asrPreset = LLMProvider.provider(id: AppGroupConfiguration.defaultCloudASRProviderId)
|
||||
providerId = polishPreset.id
|
||||
baseURL = polishPreset.defaultBaseURL
|
||||
apiKey = ""
|
||||
model = polishPreset.defaultModel
|
||||
asrProviderId = asrPreset.id
|
||||
asrBaseURL = asrPreset.defaultBaseURL
|
||||
asrModel = CloudASRModelCatalog.defaultModel(for: asrPreset.id)
|
||||
asrApiKey = ""
|
||||
handednessPreference = .left
|
||||
localASRCustomLanguageModelEnabled = true
|
||||
llmThinkingEnabled = false
|
||||
hasAcknowledgedCloudSharing = false
|
||||
configuration.providerId = polishPreset.id
|
||||
configuration.baseURL = polishPreset.defaultBaseURL
|
||||
configuration.model = polishPreset.defaultModel
|
||||
configuration.asrProviderId = asrPreset.id
|
||||
configuration.asrBaseURL = asrPreset.defaultBaseURL
|
||||
configuration.asrModel = CloudASRModelCatalog.defaultModel(for: asrPreset.id)
|
||||
configuration.handednessPreference = .left
|
||||
configuration.localASRCustomLanguageModelEnabled = true
|
||||
configuration.llmThinkingEnabled = false
|
||||
configuration.hasAcknowledgedCloudSharing = false
|
||||
isApplyingConfiguration = false
|
||||
persistConfiguration()
|
||||
}
|
||||
|
||||
private func persistConfiguration(postConfigChanged: Bool = false) {
|
||||
@@ -401,6 +431,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
handednessPreference = fresh.handednessPreference
|
||||
cursorDragNavigationEnabled = fresh.cursorDragNavigationEnabled
|
||||
polishIntensity = fresh.polishIntensity
|
||||
llmThinkingEnabled = fresh.llmThinkingEnabled
|
||||
flowSkipAppSwitch = fresh.flowSkipAppSwitch
|
||||
flowInactivityDuration = fresh.flowInactivityDuration
|
||||
localASRCustomLanguageModelEnabled = fresh.localASRCustomLanguageModelEnabled
|
||||
@@ -455,31 +486,4 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
isApplyingConfiguration = false
|
||||
persistConfiguration()
|
||||
}
|
||||
|
||||
public func reset() {
|
||||
isApplyingConfiguration = true
|
||||
let preset = LLMProvider.provider(id: "openai")
|
||||
providerId = preset.id
|
||||
baseURL = preset.defaultBaseURL
|
||||
apiKey = ""
|
||||
model = preset.defaultModel
|
||||
asrProviderId = preset.id
|
||||
asrBaseURL = preset.defaultBaseURL
|
||||
asrModel = CloudASRModelCatalog.defaultModel(for: preset.id)
|
||||
asrApiKey = ""
|
||||
handednessPreference = .left
|
||||
localASRCustomLanguageModelEnabled = true
|
||||
hasAcknowledgedCloudSharing = false
|
||||
configuration.providerId = preset.id
|
||||
configuration.baseURL = preset.defaultBaseURL
|
||||
configuration.model = preset.defaultModel
|
||||
configuration.asrProviderId = preset.id
|
||||
configuration.asrBaseURL = preset.defaultBaseURL
|
||||
configuration.asrModel = CloudASRModelCatalog.defaultModel(for: preset.id)
|
||||
configuration.handednessPreference = .left
|
||||
configuration.localASRCustomLanguageModelEnabled = true
|
||||
configuration.hasAcknowledgedCloudSharing = false
|
||||
isApplyingConfiguration = false
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,24 @@ public enum ProviderLogo {
|
||||
/// Asset name for the provider's logo, or `nil` when there is no bundled logo.
|
||||
public static func assetName(for providerId: String) -> String? {
|
||||
switch providerId {
|
||||
case "openai": return "openai"
|
||||
case "openai", "whisper": return "openai"
|
||||
case "deepseek": return "deepseek"
|
||||
case "qwen": return "qwen"
|
||||
case "qwen", "bailian", "alibabaCoding": return "qwen"
|
||||
case "moonshot": return "moonshot"
|
||||
case "zhipu": return "zhipu"
|
||||
case "mimo": return "mimo"
|
||||
case "ark", "volcengine": return "ark"
|
||||
case "siliconflow": return "siliconflow"
|
||||
case "groq": return "groq"
|
||||
case "minimax": return "minimax"
|
||||
case "openrouter": return "openrouter"
|
||||
case "gemini": return "gemini"
|
||||
case "anthropic": return "anthropic"
|
||||
case "xai": return "xai"
|
||||
case "mistral": return "mistral"
|
||||
case "cometapi": return "cometapi"
|
||||
case "codingPlanX": return "codingplanx"
|
||||
case "codex_oauth": return "openai"
|
||||
case "apple": return "apple"
|
||||
case "custom": return "custom"
|
||||
default: return nil
|
||||
|
||||
@@ -27,6 +27,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
public var handednessPreference: SyncedField<HandednessPreference>
|
||||
public var cursorDragNavigationEnabled: SyncedField<Bool>
|
||||
public var polishIntensity: SyncedField<PolishIntensity>
|
||||
public var llmThinkingEnabled: SyncedField<Bool>
|
||||
public var flowSkipAppSwitch: SyncedField<Bool>
|
||||
public var flowInactivityDuration: SyncedField<FlowInactivityDuration>
|
||||
|
||||
@@ -47,6 +48,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
handednessPreference: SyncedField<HandednessPreference>,
|
||||
cursorDragNavigationEnabled: SyncedField<Bool>,
|
||||
polishIntensity: SyncedField<PolishIntensity>,
|
||||
llmThinkingEnabled: SyncedField<Bool>,
|
||||
flowSkipAppSwitch: SyncedField<Bool>,
|
||||
flowInactivityDuration: SyncedField<FlowInactivityDuration>
|
||||
) {
|
||||
@@ -66,6 +68,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
self.handednessPreference = handednessPreference
|
||||
self.cursorDragNavigationEnabled = cursorDragNavigationEnabled
|
||||
self.polishIntensity = polishIntensity
|
||||
self.llmThinkingEnabled = llmThinkingEnabled
|
||||
self.flowSkipAppSwitch = flowSkipAppSwitch
|
||||
self.flowInactivityDuration = flowInactivityDuration
|
||||
}
|
||||
@@ -87,6 +90,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
case handednessPreference
|
||||
case cursorDragNavigationEnabled
|
||||
case polishIntensity
|
||||
case llmThinkingEnabled
|
||||
case flowSkipAppSwitch
|
||||
case flowInactivityDuration
|
||||
}
|
||||
@@ -115,6 +119,10 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
forKey: .cursorDragNavigationEnabled
|
||||
)
|
||||
polishIntensity = try container.decode(SyncedField<PolishIntensity>.self, forKey: .polishIntensity)
|
||||
llmThinkingEnabled = try container.decodeIfPresent(
|
||||
SyncedField<Bool>.self,
|
||||
forKey: .llmThinkingEnabled
|
||||
) ?? SyncedField(value: false, updatedAt: polishIntensity.updatedAt, deviceID: polishIntensity.deviceID)
|
||||
flowSkipAppSwitch = try container.decode(SyncedField<Bool>.self, forKey: .flowSkipAppSwitch)
|
||||
flowInactivityDuration = try container.decode(
|
||||
SyncedField<FlowInactivityDuration>.self,
|
||||
@@ -161,6 +169,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
handednessPreference.updatedAt,
|
||||
cursorDragNavigationEnabled.updatedAt,
|
||||
polishIntensity.updatedAt,
|
||||
llmThinkingEnabled.updatedAt,
|
||||
flowSkipAppSwitch.updatedAt,
|
||||
flowInactivityDuration.updatedAt,
|
||||
].max() ?? .distantPast
|
||||
@@ -197,6 +206,7 @@ public extension SyncedAppSettingsV2 {
|
||||
handednessPreference: field(configuration.handednessPreference),
|
||||
cursorDragNavigationEnabled: field(configuration.cursorDragNavigationEnabled),
|
||||
polishIntensity: field(configuration.polishIntensity),
|
||||
llmThinkingEnabled: field(configuration.llmThinkingEnabled),
|
||||
flowSkipAppSwitch: field(configuration.flowSkipAppSwitch),
|
||||
flowInactivityDuration: field(configuration.flowInactivityDuration)
|
||||
)
|
||||
@@ -225,6 +235,7 @@ public extension SyncedAppSettingsV2 {
|
||||
handednessPreference: field(legacy.handednessPreference),
|
||||
cursorDragNavigationEnabled: field(legacy.cursorDragNavigationEnabled),
|
||||
polishIntensity: field(legacy.polishIntensity),
|
||||
llmThinkingEnabled: field(false),
|
||||
flowSkipAppSwitch: field(legacy.flowSkipAppSwitch),
|
||||
flowInactivityDuration: field(legacy.flowInactivityDuration)
|
||||
)
|
||||
@@ -256,6 +267,7 @@ public extension SyncedAppSettingsV2 {
|
||||
remote: remote.cursorDragNavigationEnabled
|
||||
),
|
||||
polishIntensity: .merge(local: local.polishIntensity, remote: remote.polishIntensity),
|
||||
llmThinkingEnabled: .merge(local: local.llmThinkingEnabled, remote: remote.llmThinkingEnabled),
|
||||
flowSkipAppSwitch: .merge(local: local.flowSkipAppSwitch, remote: remote.flowSkipAppSwitch),
|
||||
flowInactivityDuration: .merge(
|
||||
local: local.flowInactivityDuration,
|
||||
@@ -280,6 +292,7 @@ public extension SyncedAppSettingsV2 {
|
||||
configuration.handednessPreference = handednessPreference.value
|
||||
configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled.value
|
||||
configuration.polishIntensity = polishIntensity.value
|
||||
configuration.llmThinkingEnabled = llmThinkingEnabled.value
|
||||
configuration.flowSkipAppSwitch = flowSkipAppSwitch.value
|
||||
configuration.flowInactivityDuration = flowInactivityDuration.value
|
||||
}
|
||||
@@ -306,6 +319,7 @@ public extension SyncedAppSettingsV2 {
|
||||
patch(©.handednessPreference, value: configuration.handednessPreference)
|
||||
patch(©.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled)
|
||||
patch(©.polishIntensity, value: configuration.polishIntensity)
|
||||
patch(©.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
|
||||
patch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
|
||||
patch(©.flowInactivityDuration, value: configuration.flowInactivityDuration)
|
||||
return copy
|
||||
@@ -335,6 +349,7 @@ public extension SyncedAppSettingsV2 {
|
||||
touch(©.handednessPreference, value: configuration.handednessPreference)
|
||||
touch(©.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled)
|
||||
touch(©.polishIntensity, value: configuration.polishIntensity)
|
||||
touch(©.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
|
||||
touch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
|
||||
touch(©.flowInactivityDuration, value: configuration.flowInactivityDuration)
|
||||
return copy
|
||||
|
||||
@@ -5,30 +5,78 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Local-calendar day key (`yyyy-MM-dd`) for daily usage buckets. String keys
|
||||
/// sort lexicographically in chronological order, which keeps pruning and
|
||||
/// range queries index-free.
|
||||
public enum UsageStatisticsDayKey {
|
||||
public static func key(for date: Date, calendar: Calendar = .current) -> String {
|
||||
let c = calendar.dateComponents([.year, .month, .day], from: date)
|
||||
return String(format: "%04d-%02d-%02d", c.year ?? 0, c.month ?? 0, c.day ?? 0)
|
||||
}
|
||||
|
||||
/// Drops buckets older than `days` so the synced blob stays small even
|
||||
/// after months of use (the chart only ever needs the last 7 days).
|
||||
public static func prune(_ daily: inout [String: Int], keepingDays days: Int, now: Date = Date(), calendar: Calendar = .current) {
|
||||
guard let cutoff = calendar.date(byAdding: .day, value: -days, to: now) else { return }
|
||||
let cutoffKey = key(for: cutoff, calendar: calendar)
|
||||
daily = daily.filter { $0.key >= cutoffKey }
|
||||
}
|
||||
}
|
||||
|
||||
public struct UsageStatisticsDeviceSlice: Codable, Equatable, Sendable {
|
||||
public var updatedAt: Date
|
||||
public var dictationDurationSeconds: TimeInterval
|
||||
public var dictationCharacterCount: Int
|
||||
public var translationCharacterCount: Int
|
||||
/// Grow-only per-day dictation character counts, keyed by local `yyyy-MM-dd`.
|
||||
/// Powers the home page's 7-day chart; merged per-key with `max` (each device
|
||||
/// only ever grows its own days) and summed across devices when aggregated.
|
||||
public var dailyDictationCharacters: [String: Int]
|
||||
|
||||
public init(
|
||||
updatedAt: Date = Date(),
|
||||
dictationDurationSeconds: TimeInterval = 0,
|
||||
dictationCharacterCount: Int = 0,
|
||||
translationCharacterCount: Int = 0
|
||||
translationCharacterCount: Int = 0,
|
||||
dailyDictationCharacters: [String: Int] = [:]
|
||||
) {
|
||||
self.updatedAt = updatedAt
|
||||
self.dictationDurationSeconds = dictationDurationSeconds
|
||||
self.dictationCharacterCount = dictationCharacterCount
|
||||
self.translationCharacterCount = translationCharacterCount
|
||||
self.dailyDictationCharacters = dailyDictationCharacters
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case updatedAt
|
||||
case dictationDurationSeconds
|
||||
case dictationCharacterCount
|
||||
case translationCharacterCount
|
||||
case dailyDictationCharacters
|
||||
}
|
||||
|
||||
// Custom decode so slices written before the daily-buckets field still load
|
||||
// (the missing key defaults to an empty map rather than failing the decode).
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
||||
dictationDurationSeconds = try container.decode(TimeInterval.self, forKey: .dictationDurationSeconds)
|
||||
dictationCharacterCount = try container.decode(Int.self, forKey: .dictationCharacterCount)
|
||||
translationCharacterCount = try container.decode(Int.self, forKey: .translationCharacterCount)
|
||||
dailyDictationCharacters = try container.decodeIfPresent([String: Int].self, forKey: .dailyDictationCharacters) ?? [:]
|
||||
}
|
||||
|
||||
public static func merge(local: UsageStatisticsDeviceSlice, remote: UsageStatisticsDeviceSlice) -> UsageStatisticsDeviceSlice {
|
||||
UsageStatisticsDeviceSlice(
|
||||
var mergedDaily = local.dailyDictationCharacters
|
||||
for (day, value) in remote.dailyDictationCharacters {
|
||||
mergedDaily[day] = max(mergedDaily[day] ?? 0, value)
|
||||
}
|
||||
return UsageStatisticsDeviceSlice(
|
||||
updatedAt: max(local.updatedAt, remote.updatedAt),
|
||||
dictationDurationSeconds: max(local.dictationDurationSeconds, remote.dictationDurationSeconds),
|
||||
dictationCharacterCount: max(local.dictationCharacterCount, remote.dictationCharacterCount),
|
||||
translationCharacterCount: max(local.translationCharacterCount, remote.translationCharacterCount)
|
||||
translationCharacterCount: max(local.translationCharacterCount, remote.translationCharacterCount),
|
||||
dailyDictationCharacters: mergedDaily
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,6 +123,17 @@ public struct SyncedUsageStatisticsV2: Codable, Equatable, Sendable {
|
||||
)
|
||||
}
|
||||
|
||||
/// Cross-device daily dictation characters (summed per `yyyy-MM-dd`).
|
||||
public var aggregatedDailyDictationCharacters: [String: Int] {
|
||||
var result: [String: Int] = [:]
|
||||
for slice in devices.values {
|
||||
for (day, value) in slice.dailyDictationCharacters {
|
||||
result[day, default: 0] += value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
public static func merge(local: SyncedUsageStatisticsV2, remote: SyncedUsageStatisticsV2) -> SyncedUsageStatisticsV2 {
|
||||
var mergedDevices = local.devices
|
||||
for (deviceID, remoteSlice) in remote.devices {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// VolcengineASRFields.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Parse / encode Volcengine SAUC credentials stored in the ASR API key field.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct VolcengineASRFields: Sendable, Equatable {
|
||||
public var appID: String
|
||||
public var accessToken: String
|
||||
public var resourceID: String
|
||||
|
||||
public init(
|
||||
appID: String = "",
|
||||
accessToken: String = "",
|
||||
resourceID: String = CloudASRModelCatalog.defaultModel(for: "volcengine")
|
||||
) {
|
||||
self.appID = appID
|
||||
self.accessToken = accessToken
|
||||
self.resourceID = resourceID
|
||||
}
|
||||
|
||||
public var encodedAPIKey: String {
|
||||
let object = [
|
||||
"app_id": appID,
|
||||
"access_token": accessToken,
|
||||
"resource_id": resourceID,
|
||||
]
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: object),
|
||||
let string = String(data: data, encoding: .utf8) else {
|
||||
return [appID, accessToken, resourceID].joined(separator: ":")
|
||||
}
|
||||
return string
|
||||
}
|
||||
|
||||
public static func parse(apiKey: String, resourceFallback: String) -> VolcengineASRFields {
|
||||
let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var fields = VolcengineASRFields(
|
||||
appID: "",
|
||||
accessToken: "",
|
||||
resourceID: resourceFallback.isEmpty
|
||||
? CloudASRModelCatalog.defaultModel(for: "volcengine")
|
||||
: resourceFallback
|
||||
)
|
||||
|
||||
if let data = trimmed.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
fields.appID = string(json, keys: ["app_id", "appId", "appid"]) ?? ""
|
||||
fields.accessToken = string(json, keys: ["access_token", "accessToken", "token"]) ?? ""
|
||||
fields.resourceID = string(json, keys: ["resource_id", "resourceId", "resource"]) ?? fields.resourceID
|
||||
return fields
|
||||
}
|
||||
|
||||
let parts = trimmed
|
||||
.components(separatedBy: CharacterSet(charactersIn: ":\n,"))
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
if parts.indices.contains(0) { fields.appID = parts[0] }
|
||||
if parts.indices.contains(1) { fields.accessToken = parts[1] }
|
||||
if parts.indices.contains(2) { fields.resourceID = parts[2] }
|
||||
return fields
|
||||
}
|
||||
|
||||
private static func string(_ json: [String: Any], keys: [String]) -> String? {
|
||||
for key in keys {
|
||||
if let value = json[key] as? String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty { return trimmed }
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user