feat(keyboard): ship AI mode surface with streaming search answers
Add the AI keyboard tab, Agent settings, and user-owned LLM key path for 1.7.0, including streaming answers and web-search transports without the built-in DeepSeek fallback.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
// AIResponseLength.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Soft response-length preference for AI keyboard mode. Guidance is
|
||||
// applied through the system prompt — not a hard character gate.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum AIResponseLength: String, Codable, CaseIterable, Sendable {
|
||||
case short
|
||||
case medium
|
||||
case detailed
|
||||
|
||||
public static let `default`: AIResponseLength = .medium
|
||||
|
||||
public var labelKey: String {
|
||||
switch self {
|
||||
case .short: return "ai.responseLength.short"
|
||||
case .medium: return "ai.responseLength.medium"
|
||||
case .detailed: return "ai.responseLength.detailed"
|
||||
}
|
||||
}
|
||||
|
||||
/// Soft length guidance injected into the AI-mode system prompt.
|
||||
public var promptGuidance: String {
|
||||
switch self {
|
||||
case .short:
|
||||
return "Keep the answer brief: about 2–3 sentences."
|
||||
case .medium:
|
||||
return "Keep the answer moderately long: roughly within 500 characters."
|
||||
case .detailed:
|
||||
return "You may answer in more detail: roughly within 3000 characters."
|
||||
}
|
||||
}
|
||||
|
||||
public static func resolve(storedRawValue rawValue: String?) -> AIResponseLength {
|
||||
switch rawValue {
|
||||
case AIResponseLength.short.rawValue:
|
||||
return .short
|
||||
case AIResponseLength.detailed.rawValue:
|
||||
return .detailed
|
||||
case AIResponseLength.medium.rawValue:
|
||||
return .medium
|
||||
default:
|
||||
return .default
|
||||
}
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
self = Self.resolve(storedRawValue: try container.decode(String.self))
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
try container.encode(rawValue)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// AISessionState.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Keyboard-local state for one temporary AI conversation. Conversation
|
||||
// messages live in the host process; the extension keeps only the latest
|
||||
// answer needed for review and explicit insertion.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct AIAnswer: Equatable, Identifiable, Sendable {
|
||||
public enum DeliveryState: Equatable, Sendable {
|
||||
case ready
|
||||
case awaitingSend
|
||||
case inserted
|
||||
case sent
|
||||
}
|
||||
|
||||
public let id: UUID
|
||||
public let text: String
|
||||
public let createdAt: Date
|
||||
public private(set) var deliveryState: DeliveryState
|
||||
|
||||
public var isInserted: Bool {
|
||||
deliveryState != .ready
|
||||
}
|
||||
|
||||
public var isSent: Bool {
|
||||
deliveryState == .sent
|
||||
}
|
||||
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
text: String,
|
||||
createdAt: Date = Date(),
|
||||
isSent: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.text = text
|
||||
self.createdAt = createdAt
|
||||
self.deliveryState = isSent ? .sent : .ready
|
||||
}
|
||||
|
||||
public mutating func markInserted(offersSend: Bool) {
|
||||
guard deliveryState == .ready else { return }
|
||||
deliveryState = offersSend ? .awaitingSend : .inserted
|
||||
}
|
||||
|
||||
public mutating func markSent() {
|
||||
guard deliveryState == .awaitingSend else { return }
|
||||
deliveryState = .sent
|
||||
}
|
||||
}
|
||||
|
||||
public struct AISessionState: Equatable, Sendable {
|
||||
public enum Phase: Equatable, Sendable {
|
||||
case inactive
|
||||
case idle
|
||||
case preparing
|
||||
case listening
|
||||
case recognizing
|
||||
case generating
|
||||
case ready
|
||||
case awaitingSend
|
||||
case inserted
|
||||
case sent
|
||||
case failed
|
||||
}
|
||||
|
||||
public private(set) var phase: Phase
|
||||
public private(set) var conversationID: UUID?
|
||||
public private(set) var activeUtteranceID: UUID?
|
||||
public private(set) var answer: AIAnswer?
|
||||
/// Live LLM draft while `phase == .generating`. Cleared on final/cancel.
|
||||
public private(set) var draftAnswerText: String?
|
||||
public private(set) var transcript: String
|
||||
public private(set) var errorMessage: String?
|
||||
|
||||
public static let inactive = AISessionState()
|
||||
|
||||
public init(
|
||||
phase: Phase = .inactive,
|
||||
conversationID: UUID? = nil,
|
||||
activeUtteranceID: UUID? = nil,
|
||||
answer: AIAnswer? = nil,
|
||||
draftAnswerText: String? = nil,
|
||||
transcript: String = "",
|
||||
errorMessage: String? = nil
|
||||
) {
|
||||
self.phase = phase
|
||||
self.conversationID = conversationID
|
||||
self.activeUtteranceID = activeUtteranceID
|
||||
self.answer = answer
|
||||
self.draftAnswerText = draftAnswerText
|
||||
self.transcript = transcript
|
||||
self.errorMessage = errorMessage
|
||||
}
|
||||
|
||||
public var isActive: Bool { phase != .inactive }
|
||||
|
||||
public var isBusy: Bool {
|
||||
switch phase {
|
||||
case .preparing, .listening, .recognizing, .generating:
|
||||
return true
|
||||
case .inactive, .idle, .ready, .awaitingSend, .inserted, .sent, .failed:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public var canInsert: Bool {
|
||||
phase == .ready && answer?.deliveryState == .ready
|
||||
}
|
||||
|
||||
public var canSend: Bool {
|
||||
phase == .awaitingSend && answer?.deliveryState == .awaitingSend
|
||||
}
|
||||
|
||||
public var canPerformAnswerAction: Bool {
|
||||
canInsert || canSend
|
||||
}
|
||||
|
||||
public mutating func enter(conversationID: UUID = UUID()) {
|
||||
self = AISessionState(phase: .idle, conversationID: conversationID)
|
||||
}
|
||||
|
||||
public mutating func leave() {
|
||||
self = .inactive
|
||||
}
|
||||
|
||||
public mutating func beginPreparing(utteranceID: UUID) {
|
||||
guard isActive, !isBusy else { return }
|
||||
phase = .preparing
|
||||
activeUtteranceID = utteranceID
|
||||
transcript = ""
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
public mutating func beginListening(utteranceID: UUID) {
|
||||
guard isActive, activeUtteranceID == utteranceID else { return }
|
||||
phase = .listening
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
public mutating func updateTranscript(_ value: String, utteranceID: UUID) {
|
||||
guard isActive, activeUtteranceID == utteranceID else { return }
|
||||
transcript = value
|
||||
}
|
||||
|
||||
public mutating func beginRecognizing(utteranceID: UUID) {
|
||||
guard isActive, activeUtteranceID == utteranceID else { return }
|
||||
phase = .recognizing
|
||||
}
|
||||
|
||||
public mutating func beginGenerating(question: String, utteranceID: UUID) {
|
||||
guard isActive, activeUtteranceID == utteranceID else { return }
|
||||
transcript = question
|
||||
draftAnswerText = nil
|
||||
phase = .generating
|
||||
}
|
||||
|
||||
/// Incremental AI answer draft. Keeps `phase == .generating` and does not
|
||||
/// replace the previous committed `answer` until `receiveAnswer`.
|
||||
public mutating func receivePartialAnswer(_ text: String, utteranceID: UUID) {
|
||||
guard isActive, activeUtteranceID == utteranceID else { return }
|
||||
if phase == .recognizing {
|
||||
phase = .generating
|
||||
}
|
||||
guard phase == .generating else { return }
|
||||
draftAnswerText = text
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
public mutating func receiveAnswer(_ text: String, utteranceID: UUID) {
|
||||
guard isActive, activeUtteranceID == utteranceID else { return }
|
||||
answer = AIAnswer(text: text)
|
||||
draftAnswerText = nil
|
||||
phase = .ready
|
||||
activeUtteranceID = nil
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
public mutating func markAnswerInserted(offersSend: Bool) {
|
||||
guard canInsert else { return }
|
||||
answer?.markInserted(offersSend: offersSend)
|
||||
phase = offersSend ? .awaitingSend : .inserted
|
||||
}
|
||||
|
||||
public mutating func markAnswerSent() {
|
||||
guard canSend else { return }
|
||||
answer?.markSent()
|
||||
phase = .sent
|
||||
}
|
||||
|
||||
public mutating func cancelCurrentWork() {
|
||||
guard isBusy else { return }
|
||||
activeUtteranceID = nil
|
||||
transcript = ""
|
||||
draftAnswerText = nil
|
||||
errorMessage = nil
|
||||
phase = restingPhase
|
||||
}
|
||||
|
||||
public mutating func fail(_ message: String, utteranceID: UUID?) {
|
||||
guard isActive else { return }
|
||||
if let utteranceID, activeUtteranceID != utteranceID { return }
|
||||
activeUtteranceID = nil
|
||||
draftAnswerText = nil
|
||||
errorMessage = message
|
||||
phase = .failed
|
||||
}
|
||||
|
||||
public mutating func resetConversationPreservingAnswer(
|
||||
conversationID: UUID = UUID()
|
||||
) {
|
||||
guard isActive else { return }
|
||||
self.conversationID = conversationID
|
||||
activeUtteranceID = nil
|
||||
transcript = ""
|
||||
draftAnswerText = nil
|
||||
errorMessage = nil
|
||||
phase = restingPhase
|
||||
}
|
||||
|
||||
private var restingPhase: Phase {
|
||||
guard let answer else { return .idle }
|
||||
switch answer.deliveryState {
|
||||
case .ready:
|
||||
return .ready
|
||||
case .awaitingSend:
|
||||
return .awaitingSend
|
||||
case .inserted:
|
||||
return .inserted
|
||||
case .sent:
|
||||
return .sent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum AIQuestionLimits {
|
||||
public static let retainedConversationRounds = 6
|
||||
/// Safety ceiling only — user-facing length is guided by prompt, not this cap.
|
||||
public static let maximumAnswerCharacterCount = 4_500
|
||||
}
|
||||
@@ -37,6 +37,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
public static let cursorDragNavigationEnabled = "config.cursorDragNavigationEnabled"
|
||||
public static let keyboardHapticIntensity = "config.keyboardHapticIntensity"
|
||||
public static let polishIntensity = "config.polishIntensity"
|
||||
public static let aiResponseLength = "config.aiResponseLength"
|
||||
public static let llmThinkingEnabled = "config.llmThinkingEnabled"
|
||||
public static let detectedAppContext = "config.detectedAppContext"
|
||||
public static let detectedAppContextAt = "config.detectedAppContextAt"
|
||||
@@ -89,6 +90,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
public var keyboardHapticIntensity: KeyboardHapticIntensity
|
||||
/// Safety envelope for built-in fun polish styles (light by default).
|
||||
public var polishIntensity: PolishIntensity
|
||||
/// Soft AI-mode answer length preference (medium by default).
|
||||
public var aiResponseLength: AIResponseLength
|
||||
/// Enables provider-specific reasoning / thinking controls for polish LLM requests.
|
||||
public var llmThinkingEnabled: Bool
|
||||
public var personalDictionary: PersonalDictionary
|
||||
@@ -146,10 +149,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
}
|
||||
|
||||
public var isPolishKeyMissing: Bool {
|
||||
if !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return false
|
||||
}
|
||||
return !PreconfiguredKeys.isDeepseekConfigured
|
||||
apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
public var isCloudAPIKeyMissingForVoiceInput: Bool {
|
||||
@@ -265,6 +265,9 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
polishIntensity: PolishIntensity.resolve(
|
||||
storedRawValue: defaults.string(forKey: Keys.polishIntensity)
|
||||
),
|
||||
aiResponseLength: AIResponseLength.resolve(
|
||||
storedRawValue: defaults.string(forKey: Keys.aiResponseLength)
|
||||
),
|
||||
llmThinkingEnabled: defaults.bool(forKey: Keys.llmThinkingEnabled),
|
||||
personalDictionary: decodePersonalDictionary(from: defaults),
|
||||
polishStyleCatalog: decodePolishStyleCatalog(from: defaults),
|
||||
@@ -396,6 +399,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
defaults.set(cursorDragNavigationEnabled, forKey: Keys.cursorDragNavigationEnabled)
|
||||
defaults.set(keyboardHapticIntensity.rawValue, forKey: Keys.keyboardHapticIntensity)
|
||||
defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity)
|
||||
defaults.set(aiResponseLength.rawValue, forKey: Keys.aiResponseLength)
|
||||
defaults.set(llmThinkingEnabled, forKey: Keys.llmThinkingEnabled)
|
||||
defaults.set(activePolishStyleId, forKey: Keys.activePolishStyleId)
|
||||
defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch)
|
||||
|
||||
@@ -11,6 +11,8 @@ public enum FlowUtteranceMode: String, Codable, Equatable, Sendable {
|
||||
case dictation
|
||||
/// ASR is an explicit instruction over the last verified OSG insertion.
|
||||
case editLastInput
|
||||
/// ASR is a direct question for the temporary AI conversation.
|
||||
case aiQuestion
|
||||
/// Decoded only from retired or unknown wire modes. Production code must
|
||||
/// reject this value and must never treat it as dictation.
|
||||
case unsupportedLegacy
|
||||
@@ -22,6 +24,8 @@ public enum FlowUtteranceMode: String, Codable, Equatable, Sendable {
|
||||
self = .dictation
|
||||
case Self.editLastInput.rawValue:
|
||||
self = .editLastInput
|
||||
case Self.aiQuestion.rawValue:
|
||||
self = .aiQuestion
|
||||
case "clipboardCommand", Self.unsupportedLegacy.rawValue:
|
||||
self = .unsupportedLegacy
|
||||
default:
|
||||
|
||||
@@ -10,6 +10,7 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
||||
public let editSourceText: String?
|
||||
public let sourceHistoryEntryID: UUID?
|
||||
public let sourceHistoryEntryRevision: Int64?
|
||||
public let aiConversationID: UUID?
|
||||
|
||||
public static let dictation = FlowUtteranceRequest(mode: .dictation)
|
||||
|
||||
@@ -17,12 +18,14 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
||||
mode: FlowUtteranceMode,
|
||||
editSourceText: String? = nil,
|
||||
sourceHistoryEntryID: UUID? = nil,
|
||||
sourceHistoryEntryRevision: Int64? = nil
|
||||
sourceHistoryEntryRevision: Int64? = nil,
|
||||
aiConversationID: UUID? = nil
|
||||
) {
|
||||
self.mode = mode
|
||||
self.editSourceText = editSourceText
|
||||
self.sourceHistoryEntryID = sourceHistoryEntryID
|
||||
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
|
||||
self.aiConversationID = aiConversationID
|
||||
}
|
||||
|
||||
public static func editLastInput(
|
||||
@@ -37,6 +40,14 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
||||
}
|
||||
|
||||
public var isEdit: Bool { mode == .editLastInput }
|
||||
public var isAIQuestion: Bool { mode == .aiQuestion }
|
||||
|
||||
public static func aiQuestion(conversationID: UUID) -> FlowUtteranceRequest {
|
||||
FlowUtteranceRequest(
|
||||
mode: .aiQuestion,
|
||||
aiConversationID: conversationID
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public enum FlowUtteranceStartRejection: Equatable, Sendable {
|
||||
|
||||
@@ -44,9 +44,9 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
defaultBaseURL: "https://api.openai.com/v1",
|
||||
defaultModel: "gpt-4o-mini",
|
||||
defaultModel: "gpt-5.4-mini",
|
||||
apiKeyURL: URL(string: "https://platform.openai.com/api-keys"),
|
||||
blurb: "GPT-4o mini · 多语言 · Multilingual"
|
||||
blurb: "gpt-5.4-mini · Responses web_search · AI 模式可联网"
|
||||
),
|
||||
.init(
|
||||
id: "ark",
|
||||
@@ -54,7 +54,7 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
||||
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"
|
||||
blurb: "豆包 / DeepSeek · 接入点 ID 需在控制台确认"
|
||||
),
|
||||
.init(
|
||||
id: "deepseek",
|
||||
@@ -62,39 +62,39 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
||||
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 optional built-in"
|
||||
blurb: "deepseek-v4-flash · Responses 联网 · 润色/AI 共用"
|
||||
),
|
||||
.init(
|
||||
id: "qwen",
|
||||
name: "Qwen (DashScope)",
|
||||
defaultBaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
defaultModel: "qwen-plus",
|
||||
defaultModel: "qwen-plus-latest",
|
||||
apiKeyURL: URL(string: "https://dashscope.console.aliyun.com/apiKey"),
|
||||
blurb: "通义千问 · OpenAI 兼容 · OpenAI-compatible"
|
||||
blurb: "qwen-plus-latest · enable_search · 滚动最新 Plus"
|
||||
),
|
||||
.init(
|
||||
id: "zhipu",
|
||||
name: "智谱 GLM · Zhipu",
|
||||
defaultBaseURL: "https://open.bigmodel.cn/api/paas/v4",
|
||||
defaultModel: "glm-4-flash",
|
||||
defaultModel: "glm-4.7-flash",
|
||||
apiKeyURL: URL(string: "https://bigmodel.cn/usercenter/apikeys"),
|
||||
blurb: "GLM-4-Flash · 中文优化 · Chinese-optimized"
|
||||
blurb: "GLM-4.7-Flash · 快 · 可 web_search"
|
||||
),
|
||||
.init(
|
||||
id: "moonshot",
|
||||
name: "月之暗面 Moonshot",
|
||||
defaultBaseURL: "https://api.moonshot.cn/v1",
|
||||
defaultModel: "moonshot-v1-8k",
|
||||
defaultModel: "kimi-k2.5",
|
||||
apiKeyURL: URL(string: "https://platform.moonshot.cn/console/api-keys"),
|
||||
blurb: "Kimi · 长上下文 · Long context"
|
||||
blurb: "kimi-k2.5 · 长上下文 · AI 可尝试联网"
|
||||
),
|
||||
.init(
|
||||
id: "siliconflow",
|
||||
name: "硅基流动 SiliconFlow",
|
||||
defaultBaseURL: "https://api.siliconflow.cn/v1",
|
||||
defaultModel: "Qwen/Qwen2.5-7B-Instruct",
|
||||
defaultModel: "Qwen/Qwen3-8B-Instruct",
|
||||
apiKeyURL: URL(string: "https://cloud.siliconflow.cn/account/ak"),
|
||||
blurb: "多模型聚合 · OpenAI 兼容 · OpenAI-compatible"
|
||||
blurb: "Qwen3-8B · 多模型聚合 · OpenAI 兼容"
|
||||
),
|
||||
.init(
|
||||
id: "groq",
|
||||
@@ -102,15 +102,15 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
||||
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"
|
||||
blurb: "Llama 3.3 70B · 超低延迟 LPU"
|
||||
),
|
||||
.init(
|
||||
id: "minimax",
|
||||
name: "MiniMax",
|
||||
defaultBaseURL: "https://api.minimaxi.com/v1",
|
||||
defaultModel: "MiniMax-M2.5",
|
||||
defaultModel: "MiniMax-M2.7",
|
||||
apiKeyURL: URL(string: "https://platform.minimaxi.com/user-center/basic-information"),
|
||||
blurb: "MiniMax-M2.5 · 中文优化 · Chinese-optimized"
|
||||
blurb: "MiniMax-M2.7 · 中文优化"
|
||||
),
|
||||
.init(
|
||||
id: "mimo",
|
||||
@@ -118,23 +118,23 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
||||
defaultBaseURL: "https://api.xiaomimimo.com/v1",
|
||||
defaultModel: "mimo-v2.5",
|
||||
apiKeyURL: URL(string: "https://platform.xiaomimimo.com"),
|
||||
blurb: "mimo-v2.5 · 中文优化 · Chinese-optimized"
|
||||
blurb: "mimo-v2.5 · 中文优化"
|
||||
),
|
||||
.init(
|
||||
id: "openrouter",
|
||||
name: "OpenRouter",
|
||||
defaultBaseURL: "https://openrouter.ai/api/v1",
|
||||
defaultModel: "qwen/qwen3-coder:free",
|
||||
defaultModel: "qwen/qwen3-8b:free",
|
||||
apiKeyURL: URL(string: "https://openrouter.ai/keys"),
|
||||
blurb: "多模型路由 · Model routing · OpenAI-compatible"
|
||||
blurb: "qwen3-8b:free · 通用润色/问答(非 coder)"
|
||||
),
|
||||
.init(
|
||||
id: "gemini",
|
||||
name: "Google Gemini",
|
||||
defaultBaseURL: "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
defaultModel: "gemini-2.5-flash",
|
||||
defaultModel: "gemini-3.1-flash-lite",
|
||||
apiKeyURL: URL(string: "https://aistudio.google.com/apikey"),
|
||||
blurb: "Gemini 2.5 Flash · OpenAI 兼容端点"
|
||||
blurb: "gemini-3.1-flash-lite · 低延迟 · OpenAI 兼容端点"
|
||||
),
|
||||
.init(
|
||||
id: "anthropic",
|
||||
@@ -142,15 +142,15 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
||||
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"
|
||||
blurb: "Claude Sonnet 4.6 · Messages · AI 可联网"
|
||||
),
|
||||
.init(
|
||||
id: "xai",
|
||||
name: "xAI Grok",
|
||||
defaultBaseURL: "https://api.x.ai/v1",
|
||||
defaultModel: "grok-3-mini",
|
||||
defaultModel: "grok-4-fast-reasoning",
|
||||
apiKeyURL: URL(string: "https://console.x.ai"),
|
||||
blurb: "Grok · OpenAI 兼容 · OpenAI-compatible"
|
||||
blurb: "grok-4-fast-reasoning · Responses web_search"
|
||||
),
|
||||
.init(
|
||||
id: "mistral",
|
||||
@@ -158,15 +158,15 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
||||
defaultBaseURL: "https://api.mistral.ai/v1",
|
||||
defaultModel: "mistral-small-latest",
|
||||
apiKeyURL: URL(string: "https://console.mistral.ai/api-keys"),
|
||||
blurb: "Mistral Small · 欧洲托管 · EU-hosted"
|
||||
blurb: "Mistral Small · 欧洲托管 · -latest 滚动"
|
||||
),
|
||||
.init(
|
||||
id: "cometapi",
|
||||
name: "CometAPI",
|
||||
defaultBaseURL: "https://api.cometapi.com/v1",
|
||||
defaultModel: "gpt-4o",
|
||||
defaultModel: "gpt-5.4-mini",
|
||||
apiKeyURL: URL(string: "https://api.cometapi.com"),
|
||||
blurb: "多模型聚合 · OpenAI 兼容"
|
||||
blurb: "gpt-5.4-mini · 多模型聚合"
|
||||
),
|
||||
.init(
|
||||
id: "alibabaCoding",
|
||||
@@ -174,15 +174,15 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
||||
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"
|
||||
blurb: "通义 Coder · 代码润色"
|
||||
),
|
||||
.init(
|
||||
id: "codingPlanX",
|
||||
name: "CodingPlanX",
|
||||
defaultBaseURL: "https://api.codingplanx.ai/v1",
|
||||
defaultModel: "gpt-5-mini",
|
||||
defaultModel: "gpt-5.4-mini",
|
||||
apiKeyURL: URL(string: "https://codingplanx.ai"),
|
||||
blurb: "CodingPlanX · OpenAI 兼容"
|
||||
blurb: "gpt-5.4-mini · OpenAI 兼容"
|
||||
),
|
||||
// MARK: - ASR-only presets (hidden from polish picker)
|
||||
.init(
|
||||
|
||||
@@ -20,7 +20,7 @@ public struct LLMRequest: Codable, Sendable {
|
||||
case topP = "top_p"
|
||||
}
|
||||
|
||||
public enum Message: Codable, Sendable {
|
||||
public enum Message: Codable, Equatable, Sendable {
|
||||
case system(String)
|
||||
case user(String)
|
||||
case assistant(String)
|
||||
@@ -54,6 +54,21 @@ public struct LLMRequest: Codable, Sendable {
|
||||
debugDescription: "Unknown role \(role)")
|
||||
}
|
||||
}
|
||||
|
||||
public var role: String {
|
||||
switch self {
|
||||
case .system: return "system"
|
||||
case .user: return "user"
|
||||
case .assistant: return "assistant"
|
||||
}
|
||||
}
|
||||
|
||||
public var content: String {
|
||||
switch self {
|
||||
case .system(let value), .user(let value), .assistant(let value):
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public init(
|
||||
|
||||
@@ -105,7 +105,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
/// "local" → on-device ASR + user's LLM polish (or built-in DeepSeek).
|
||||
/// "local" → on-device ASR + user's LLM polish (requires user API key).
|
||||
/// "cloud" → user's cloud ASR + user's cloud LLM polish (independent picks).
|
||||
@Published public var engineMode: String {
|
||||
didSet {
|
||||
@@ -230,6 +230,16 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Soft AI-mode answer length preference (short / medium / detailed).
|
||||
@Published public var aiResponseLength: AIResponseLength {
|
||||
didSet {
|
||||
guard !isApplyingConfiguration,
|
||||
aiResponseLength != configuration.aiResponseLength else { return }
|
||||
configuration.aiResponseLength = aiResponseLength
|
||||
persistConfiguration(postConfigChanged: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the pipeline should run translate-and-polish (not just
|
||||
/// polish). Both engines honour the selected target locale.
|
||||
public var isTranslationEffective: Bool {
|
||||
@@ -290,10 +300,10 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
}
|
||||
|
||||
public var isPolishConfigured: Bool {
|
||||
if !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return !baseURL.isEmpty && !model.isEmpty
|
||||
guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return false
|
||||
}
|
||||
return PreconfiguredKeys.isDeepseekConfigured
|
||||
return !baseURL.isEmpty && !model.isEmpty
|
||||
}
|
||||
|
||||
public var isASRConfigured: Bool {
|
||||
@@ -312,9 +322,9 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
/// On-device ASR only; no cloud API required.
|
||||
public var isLocalEngine: Bool { configuration.isLocalEngine }
|
||||
|
||||
/// Built-in DeepSeek path when the user has not supplied their own LLM key.
|
||||
/// Polish provider used by the local engine (same Settings selection as cloud polish).
|
||||
public var localModeProviderId: String {
|
||||
apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "deepseek" : providerId
|
||||
providerId
|
||||
}
|
||||
|
||||
private let defaults: UserDefaults
|
||||
@@ -389,6 +399,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
cursorDragNavigationEnabled = configuration.cursorDragNavigationEnabled
|
||||
keyboardHapticIntensity = configuration.keyboardHapticIntensity
|
||||
polishIntensity = configuration.polishIntensity
|
||||
aiResponseLength = configuration.aiResponseLength
|
||||
llmThinkingEnabled = configuration.llmThinkingEnabled
|
||||
flowSkipAppSwitch = configuration.flowSkipAppSwitch
|
||||
flowInactivityDuration = configuration.flowInactivityDuration
|
||||
@@ -417,6 +428,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
handednessPreference = .left
|
||||
keyboardHapticIntensity = .default
|
||||
polishIntensity = .default
|
||||
aiResponseLength = .default
|
||||
localASRCustomLanguageModelEnabled = true
|
||||
llmThinkingEnabled = false
|
||||
hasAcknowledgedCloudSharing = false
|
||||
@@ -429,6 +441,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
configuration.handednessPreference = .left
|
||||
configuration.keyboardHapticIntensity = .default
|
||||
configuration.polishIntensity = .default
|
||||
configuration.aiResponseLength = .default
|
||||
configuration.localASRCustomLanguageModelEnabled = true
|
||||
configuration.llmThinkingEnabled = false
|
||||
configuration.hasAcknowledgedCloudSharing = false
|
||||
@@ -480,6 +493,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
cursorDragNavigationEnabled = fresh.cursorDragNavigationEnabled
|
||||
keyboardHapticIntensity = fresh.keyboardHapticIntensity
|
||||
polishIntensity = fresh.polishIntensity
|
||||
aiResponseLength = fresh.aiResponseLength
|
||||
llmThinkingEnabled = fresh.llmThinkingEnabled
|
||||
flowSkipAppSwitch = fresh.flowSkipAppSwitch
|
||||
flowInactivityDuration = fresh.flowInactivityDuration
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
import Foundation
|
||||
|
||||
public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
|
||||
public enum Source: String, Codable, Equatable, Sendable {
|
||||
case dictation
|
||||
case ai
|
||||
}
|
||||
|
||||
public let id: UUID
|
||||
public let text: String
|
||||
public let createdAt: Date
|
||||
@@ -15,6 +20,8 @@ public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
|
||||
public let revision: Int64
|
||||
/// iOS Flow engine mode; nil on macOS captures.
|
||||
public let engineMode: String?
|
||||
/// Origin of the inserted text. Legacy rows decode as normal dictation.
|
||||
public let source: Source
|
||||
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
@@ -22,7 +29,8 @@ public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
|
||||
createdAt: Date = Date(),
|
||||
modifiedAt: Date? = nil,
|
||||
revision: Int64 = 0,
|
||||
engineMode: String? = nil
|
||||
engineMode: String? = nil,
|
||||
source: Source = .dictation
|
||||
) {
|
||||
self.id = id
|
||||
self.text = text
|
||||
@@ -30,6 +38,7 @@ public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
|
||||
self.modifiedAt = modifiedAt ?? createdAt
|
||||
self.revision = revision
|
||||
self.engineMode = engineMode
|
||||
self.source = source
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
@@ -40,6 +49,7 @@ public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
|
||||
modifiedAt = try container.decodeIfPresent(Date.self, forKey: .modifiedAt) ?? createdAt
|
||||
revision = try container.decodeIfPresent(Int64.self, forKey: .revision) ?? 0
|
||||
engineMode = try container.decodeIfPresent(String.self, forKey: .engineMode)
|
||||
source = try container.decodeIfPresent(Source.self, forKey: .source) ?? .dictation
|
||||
}
|
||||
|
||||
/// First-line preview for compact list rows (macOS history sidebar).
|
||||
|
||||
@@ -28,6 +28,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
public var cursorDragNavigationEnabled: SyncedField<Bool>
|
||||
public var keyboardHapticIntensity: SyncedField<KeyboardHapticIntensity>
|
||||
public var polishIntensity: SyncedField<PolishIntensity>
|
||||
public var aiResponseLength: SyncedField<AIResponseLength>
|
||||
public var activePolishStyleId: SyncedField<String>
|
||||
public var llmThinkingEnabled: SyncedField<Bool>
|
||||
public var flowSkipAppSwitch: SyncedField<Bool>
|
||||
@@ -51,6 +52,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
cursorDragNavigationEnabled: SyncedField<Bool>,
|
||||
keyboardHapticIntensity: SyncedField<KeyboardHapticIntensity>,
|
||||
polishIntensity: SyncedField<PolishIntensity>? = nil,
|
||||
aiResponseLength: SyncedField<AIResponseLength>? = nil,
|
||||
activePolishStyleId: SyncedField<String>,
|
||||
llmThinkingEnabled: SyncedField<Bool>,
|
||||
flowSkipAppSwitch: SyncedField<Bool>,
|
||||
@@ -77,6 +79,11 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
updatedAt: keyboardHapticIntensity.updatedAt,
|
||||
deviceID: keyboardHapticIntensity.deviceID
|
||||
)
|
||||
self.aiResponseLength = aiResponseLength ?? SyncedField(
|
||||
value: .default,
|
||||
updatedAt: keyboardHapticIntensity.updatedAt,
|
||||
deviceID: keyboardHapticIntensity.deviceID
|
||||
)
|
||||
self.activePolishStyleId = activePolishStyleId
|
||||
self.llmThinkingEnabled = llmThinkingEnabled
|
||||
self.flowSkipAppSwitch = flowSkipAppSwitch
|
||||
@@ -101,6 +108,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
case cursorDragNavigationEnabled
|
||||
case keyboardHapticIntensity
|
||||
case polishIntensity
|
||||
case aiResponseLength
|
||||
case activePolishStyleId
|
||||
case llmThinkingEnabled
|
||||
case flowSkipAppSwitch
|
||||
@@ -150,6 +158,14 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
updatedAt: keyboardHapticIntensity.updatedAt,
|
||||
deviceID: keyboardHapticIntensity.deviceID
|
||||
)
|
||||
aiResponseLength = try container.decodeIfPresent(
|
||||
SyncedField<AIResponseLength>.self,
|
||||
forKey: .aiResponseLength
|
||||
) ?? SyncedField(
|
||||
value: .default,
|
||||
updatedAt: keyboardHapticIntensity.updatedAt,
|
||||
deviceID: keyboardHapticIntensity.deviceID
|
||||
)
|
||||
activePolishStyleId = try container.decodeIfPresent(
|
||||
SyncedField<String>.self,
|
||||
forKey: .activePolishStyleId
|
||||
@@ -218,6 +234,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
cursorDragNavigationEnabled.updatedAt,
|
||||
keyboardHapticIntensity.updatedAt,
|
||||
polishIntensity.updatedAt,
|
||||
aiResponseLength.updatedAt,
|
||||
activePolishStyleId.updatedAt,
|
||||
llmThinkingEnabled.updatedAt,
|
||||
flowSkipAppSwitch.updatedAt,
|
||||
@@ -257,6 +274,7 @@ public extension SyncedAppSettingsV2 {
|
||||
cursorDragNavigationEnabled: field(configuration.cursorDragNavigationEnabled),
|
||||
keyboardHapticIntensity: field(configuration.keyboardHapticIntensity),
|
||||
polishIntensity: field(configuration.polishIntensity),
|
||||
aiResponseLength: field(configuration.aiResponseLength),
|
||||
activePolishStyleId: field(configuration.activePolishStyleId),
|
||||
llmThinkingEnabled: field(configuration.llmThinkingEnabled),
|
||||
flowSkipAppSwitch: field(configuration.flowSkipAppSwitch),
|
||||
@@ -288,6 +306,7 @@ public extension SyncedAppSettingsV2 {
|
||||
cursorDragNavigationEnabled: field(legacy.cursorDragNavigationEnabled),
|
||||
keyboardHapticIntensity: field(KeyboardHapticIntensity.default),
|
||||
polishIntensity: field(PolishIntensity.default),
|
||||
aiResponseLength: field(AIResponseLength.default),
|
||||
activePolishStyleId: field(PolishStylePackCatalog.defaultID),
|
||||
llmThinkingEnabled: field(false),
|
||||
flowSkipAppSwitch: field(legacy.flowSkipAppSwitch),
|
||||
@@ -328,6 +347,10 @@ public extension SyncedAppSettingsV2 {
|
||||
local: local.polishIntensity,
|
||||
remote: remote.polishIntensity
|
||||
),
|
||||
aiResponseLength: .merge(
|
||||
local: local.aiResponseLength,
|
||||
remote: remote.aiResponseLength
|
||||
),
|
||||
activePolishStyleId: .merge(
|
||||
local: local.activePolishStyleId,
|
||||
remote: remote.activePolishStyleId
|
||||
@@ -358,6 +381,7 @@ public extension SyncedAppSettingsV2 {
|
||||
configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled.value
|
||||
configuration.keyboardHapticIntensity = keyboardHapticIntensity.value
|
||||
configuration.polishIntensity = polishIntensity.value
|
||||
configuration.aiResponseLength = aiResponseLength.value
|
||||
configuration.activePolishStyleId = activePolishStyleId.value
|
||||
configuration.llmThinkingEnabled = llmThinkingEnabled.value
|
||||
configuration.flowSkipAppSwitch = flowSkipAppSwitch.value
|
||||
@@ -387,6 +411,7 @@ public extension SyncedAppSettingsV2 {
|
||||
patch(©.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled)
|
||||
patch(©.keyboardHapticIntensity, value: configuration.keyboardHapticIntensity)
|
||||
patch(©.polishIntensity, value: configuration.polishIntensity)
|
||||
patch(©.aiResponseLength, value: configuration.aiResponseLength)
|
||||
patch(©.activePolishStyleId, value: configuration.activePolishStyleId)
|
||||
patch(©.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
|
||||
patch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
|
||||
@@ -419,6 +444,7 @@ public extension SyncedAppSettingsV2 {
|
||||
touch(©.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled)
|
||||
touch(©.keyboardHapticIntensity, value: configuration.keyboardHapticIntensity)
|
||||
touch(©.polishIntensity, value: configuration.polishIntensity)
|
||||
touch(©.aiResponseLength, value: configuration.aiResponseLength)
|
||||
touch(©.activePolishStyleId, value: configuration.activePolishStyleId)
|
||||
touch(©.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
|
||||
touch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
|
||||
|
||||
@@ -28,6 +28,9 @@ public struct UsageStatisticsDeviceSlice: Codable, Equatable, Sendable {
|
||||
public var dictationDurationSeconds: TimeInterval
|
||||
public var dictationCharacterCount: Int
|
||||
public var translationCharacterCount: Int
|
||||
public var aiCharacterCount: Int
|
||||
/// Bounded idempotency window for AI insert commits from the extension.
|
||||
public var appliedAICommitIDs: [UUID]
|
||||
/// 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.
|
||||
@@ -38,12 +41,16 @@ public struct UsageStatisticsDeviceSlice: Codable, Equatable, Sendable {
|
||||
dictationDurationSeconds: TimeInterval = 0,
|
||||
dictationCharacterCount: Int = 0,
|
||||
translationCharacterCount: Int = 0,
|
||||
aiCharacterCount: Int = 0,
|
||||
appliedAICommitIDs: [UUID] = [],
|
||||
dailyDictationCharacters: [String: Int] = [:]
|
||||
) {
|
||||
self.updatedAt = updatedAt
|
||||
self.dictationDurationSeconds = dictationDurationSeconds
|
||||
self.dictationCharacterCount = dictationCharacterCount
|
||||
self.translationCharacterCount = translationCharacterCount
|
||||
self.aiCharacterCount = aiCharacterCount
|
||||
self.appliedAICommitIDs = appliedAICommitIDs
|
||||
self.dailyDictationCharacters = dailyDictationCharacters
|
||||
}
|
||||
|
||||
@@ -52,6 +59,8 @@ public struct UsageStatisticsDeviceSlice: Codable, Equatable, Sendable {
|
||||
case dictationDurationSeconds
|
||||
case dictationCharacterCount
|
||||
case translationCharacterCount
|
||||
case aiCharacterCount
|
||||
case appliedAICommitIDs
|
||||
case dailyDictationCharacters
|
||||
}
|
||||
|
||||
@@ -63,6 +72,11 @@ public struct UsageStatisticsDeviceSlice: Codable, Equatable, Sendable {
|
||||
dictationDurationSeconds = try container.decode(TimeInterval.self, forKey: .dictationDurationSeconds)
|
||||
dictationCharacterCount = try container.decode(Int.self, forKey: .dictationCharacterCount)
|
||||
translationCharacterCount = try container.decode(Int.self, forKey: .translationCharacterCount)
|
||||
aiCharacterCount = try container.decodeIfPresent(Int.self, forKey: .aiCharacterCount) ?? 0
|
||||
appliedAICommitIDs = try container.decodeIfPresent(
|
||||
[UUID].self,
|
||||
forKey: .appliedAICommitIDs
|
||||
) ?? []
|
||||
dailyDictationCharacters = try container.decodeIfPresent([String: Int].self, forKey: .dailyDictationCharacters) ?? [:]
|
||||
}
|
||||
|
||||
@@ -71,11 +85,22 @@ public struct UsageStatisticsDeviceSlice: Codable, Equatable, Sendable {
|
||||
for (day, value) in remote.dailyDictationCharacters {
|
||||
mergedDaily[day] = max(mergedDaily[day] ?? 0, value)
|
||||
}
|
||||
let mergedCommitIDs = Array(
|
||||
(local.appliedAICommitIDs + remote.appliedAICommitIDs)
|
||||
.reduce(into: [UUID]()) { result, id in
|
||||
if !result.contains(id) {
|
||||
result.append(id)
|
||||
}
|
||||
}
|
||||
.suffix(128)
|
||||
)
|
||||
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),
|
||||
aiCharacterCount: max(local.aiCharacterCount, remote.aiCharacterCount),
|
||||
appliedAICommitIDs: mergedCommitIDs,
|
||||
dailyDictationCharacters: mergedDaily
|
||||
)
|
||||
}
|
||||
@@ -85,13 +110,14 @@ public struct UsageStatisticsDeviceSlice: Codable, Equatable, Sendable {
|
||||
updatedAt: updatedAt,
|
||||
dictationDurationSeconds: dictationDurationSeconds,
|
||||
dictationCharacterCount: dictationCharacterCount,
|
||||
translationCharacterCount: translationCharacterCount
|
||||
translationCharacterCount: translationCharacterCount,
|
||||
aiCharacterCount: aiCharacterCount
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public struct SyncedUsageStatisticsV2: Codable, Equatable, Sendable {
|
||||
public static let schemaVersion = 2
|
||||
public static let schemaVersion = 3
|
||||
public static let kvsKey = "usageStatistics.v2"
|
||||
|
||||
public var schemaVersion: Int
|
||||
@@ -108,18 +134,21 @@ public struct SyncedUsageStatisticsV2: Codable, Equatable, Sendable {
|
||||
var duration: TimeInterval = 0
|
||||
var dictation = 0
|
||||
var translation = 0
|
||||
var ai = 0
|
||||
var latest = Date.distantPast
|
||||
for slice in devices.values {
|
||||
duration += slice.dictationDurationSeconds
|
||||
dictation += slice.dictationCharacterCount
|
||||
translation += slice.translationCharacterCount
|
||||
ai += slice.aiCharacterCount
|
||||
latest = max(latest, slice.updatedAt)
|
||||
}
|
||||
return UsageStatistics(
|
||||
updatedAt: latest,
|
||||
dictationDurationSeconds: duration,
|
||||
dictationCharacterCount: dictation,
|
||||
translationCharacterCount: translation
|
||||
translationCharacterCount: translation,
|
||||
aiCharacterCount: ai
|
||||
)
|
||||
}
|
||||
|
||||
@@ -153,7 +182,8 @@ public struct SyncedUsageStatisticsV2: Codable, Equatable, Sendable {
|
||||
updatedAt: legacy.updatedAt,
|
||||
dictationDurationSeconds: legacy.dictationDurationSeconds,
|
||||
dictationCharacterCount: legacy.dictationCharacterCount,
|
||||
translationCharacterCount: legacy.translationCharacterCount
|
||||
translationCharacterCount: legacy.translationCharacterCount,
|
||||
aiCharacterCount: legacy.aiCharacterCount
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -168,6 +168,12 @@ public final class TypingInputConfiguration: ObservableObject {
|
||||
let store = defaults ?? AppGroup.defaultsIfAvailable
|
||||
guard let store else { return .voice }
|
||||
|
||||
// AI is an explicit product surface. Restore it as an empty temporary
|
||||
// conversation even when the general "remember surface" toggle is off.
|
||||
if store.string(forKey: Key.lastSurface) == KeyboardState.Surface.ai.rawValue {
|
||||
return .ai
|
||||
}
|
||||
|
||||
if store.bool(forKey: Key.rememberLastSurface),
|
||||
let raw = store.string(forKey: Key.lastSurface),
|
||||
let surface = KeyboardState.Surface(rawValue: raw) {
|
||||
|
||||
@@ -11,21 +11,28 @@ public struct UsageStatistics: Codable, Equatable, Sendable {
|
||||
public var dictationDurationSeconds: TimeInterval
|
||||
public var dictationCharacterCount: Int
|
||||
public var translationCharacterCount: Int
|
||||
public var aiCharacterCount: Int
|
||||
|
||||
public init(
|
||||
updatedAt: Date = Date(),
|
||||
dictationDurationSeconds: TimeInterval = 0,
|
||||
dictationCharacterCount: Int = 0,
|
||||
translationCharacterCount: Int = 0
|
||||
translationCharacterCount: Int = 0,
|
||||
aiCharacterCount: Int = 0
|
||||
) {
|
||||
self.updatedAt = updatedAt
|
||||
self.dictationDurationSeconds = dictationDurationSeconds
|
||||
self.dictationCharacterCount = dictationCharacterCount
|
||||
self.translationCharacterCount = translationCharacterCount
|
||||
self.aiCharacterCount = aiCharacterCount
|
||||
}
|
||||
|
||||
public static let zero = UsageStatistics(updatedAt: .distantPast)
|
||||
|
||||
public var totalInputCharacterCount: Int {
|
||||
dictationCharacterCount + translationCharacterCount + aiCharacterCount
|
||||
}
|
||||
|
||||
/// Combine lifetime totals from two devices. After merge, each device
|
||||
/// continues accumulating locally so `max` converges to the union.
|
||||
public static func merge(local: UsageStatistics, remote: UsageStatistics) -> UsageStatistics {
|
||||
@@ -33,9 +40,39 @@ public struct UsageStatistics: Codable, Equatable, Sendable {
|
||||
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),
|
||||
aiCharacterCount: max(local.aiCharacterCount, remote.aiCharacterCount)
|
||||
)
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case updatedAt
|
||||
case dictationDurationSeconds
|
||||
case dictationCharacterCount
|
||||
case translationCharacterCount
|
||||
case aiCharacterCount
|
||||
}
|
||||
|
||||
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.decodeIfPresent(
|
||||
TimeInterval.self,
|
||||
forKey: .dictationDurationSeconds
|
||||
) ?? 0
|
||||
dictationCharacterCount = try container.decodeIfPresent(
|
||||
Int.self,
|
||||
forKey: .dictationCharacterCount
|
||||
) ?? 0
|
||||
translationCharacterCount = try container.decodeIfPresent(
|
||||
Int.self,
|
||||
forKey: .translationCharacterCount
|
||||
) ?? 0
|
||||
aiCharacterCount = try container.decodeIfPresent(
|
||||
Int.self,
|
||||
forKey: .aiCharacterCount
|
||||
) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
public enum UsageStatisticsStorage {
|
||||
|
||||
Reference in New Issue
Block a user