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:
Rocky
2026-08-11 01:06:27 +08:00
parent f6212dcd2c
commit 3de665d254
81 changed files with 4467 additions and 398 deletions
@@ -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 23 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 {
+29 -29
View File
@@ -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(
+16 -1
View File
@@ -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(
+20 -6
View File
@@ -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(&copy.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled)
patch(&copy.keyboardHapticIntensity, value: configuration.keyboardHapticIntensity)
patch(&copy.polishIntensity, value: configuration.polishIntensity)
patch(&copy.aiResponseLength, value: configuration.aiResponseLength)
patch(&copy.activePolishStyleId, value: configuration.activePolishStyleId)
patch(&copy.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
patch(&copy.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
@@ -419,6 +444,7 @@ public extension SyncedAppSettingsV2 {
touch(&copy.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled)
touch(&copy.keyboardHapticIntensity, value: configuration.keyboardHapticIntensity)
touch(&copy.polishIntensity, value: configuration.polishIntensity)
touch(&copy.aiResponseLength, value: configuration.aiResponseLength)
touch(&copy.activePolishStyleId, value: configuration.activePolishStyleId)
touch(&copy.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
touch(&copy.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) {
+39 -2
View File
@@ -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 {