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
@@ -11,14 +11,13 @@
//
// Engine matrix:
// - `engineMode == "cloud"` user's cloud ASR + user's cloud LLM (independent)
// - `engineMode == "local"` on-device ASR + user's LLM (or built-in DeepSeek)
// - `engineMode == "local"` on-device ASR + user's LLM polish
// - Ultra-short / low-value short utterances skip the LLM entirely
// (two-tier gate in TranscriptPostProcessor)
// - Fun styles use full safeguards at light intensity and the
// formatting-only creative path at heavy intensity
// - Daily Chat keeps a local sparse-input safety brake
// - Cloud without API key raw + `.missingAPIKey` warning
// - Local without build key raw + `.missingAPIKey` warning
// - Missing polish API key raw ASR + `.missingAPIKey` warning
//
// Caller-supplied `PolishContext` carries the per-call signals:
// - `appContext` code / email / chat / document / unknown
@@ -52,8 +51,7 @@ public actor PolishingService {
public enum PolishError: Error, Equatable {
case noTranscript
case timeout
/// Local engine DeepSeek step: `PreconfiguredKeys.deepseek` is
/// still the repo placeholder, or cloud engine Keychain is empty.
/// Polish LLM Keychain entry is empty for the resolved provider.
case missingAPIKey
/// The keychain was unreadable (device locked before first unlock)
/// the key likely EXISTS; treat as transient, never as "please
@@ -220,22 +218,11 @@ public actor PolishingService {
preset: preset,
providerIdOverride: providerIdOverride
)
let apiKey: String
let userKey = Self.userAPIKey(
let apiKey = Self.userAPIKey(
store: store,
providerId: effectiveProviderId
)
if effectiveProviderId == "deepseek" {
if !userKey.isEmpty {
apiKey = userKey
} else if PreconfiguredKeys.isDeepseekConfigured {
apiKey = PreconfiguredKeys.deepseek
} else {
throw PolishError.missingAPIKey
}
} else {
apiKey = userKey
}
guard !apiKey.isEmpty else { throw PolishError.missingAPIKey }
client = LLMClientFactory.make(
providerId: effectiveProviderId,
baseURL: baseURL,
@@ -470,25 +457,11 @@ public actor PolishingService {
if let providerIdOverride {
return providerIdOverride
}
let id = store.providerId
// Local installs without a user LLM key keep using the built-in DeepSeek path.
if store.engineMode == "local",
id != "deepseek",
store.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
PreconfiguredKeys.isDeepseekConfigured {
return "deepseek"
}
return id
return store.providerId
}
internal static func hasPolishAPIKey(store: any ConfigurationStore, providerId: String) -> Bool {
if !userAPIKey(store: store, providerId: providerId).isEmpty {
return true
}
if providerId == "deepseek", PreconfiguredKeys.isDeepseekConfigured {
return true
}
return false
!userAPIKey(store: store, providerId: providerId).isEmpty
}
private static func userAPIKey(
@@ -501,6 +474,9 @@ public actor PolishingService {
return key.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// Resolve baseURL + model for polish and AI mode. Empty store fields fall
/// back to the provider preset defaults so Settings remains the single
/// source of truth for both dictation polish and AI keyboard questions.
internal static func resolveLLMEndpoint(
store: any ConfigurationStore,
preset: LLMProvider,
@@ -523,7 +499,7 @@ extension PolishingService.PolishError: LocalizedError {
case .timeout:
return "LLM polish timed out."
case .missingAPIKey:
return "Missing API key (cloud: Settings API key; local: build configuration)."
return "Missing API key — fill it in Settings before polish can run."
case .keychainLocked:
return "API key unavailable while the device is locked — will work after unlock."
}