feat(account): add managed credits and cloud gateway

Introduce optional Apple account-backed credits with scoped gateway access while preserving local and BYOK paths. Refresh assistant behavior, tests, privacy disclosures, docs, and the website for the 2.0 experience.
This commit is contained in:
Rocky
2026-08-20 11:43:21 +08:00
parent 0f9280bd00
commit 498f407585
301 changed files with 19221 additions and 10891 deletions
@@ -26,6 +26,7 @@ public protocol ConfigurationStore: Sendable {
var asrModel: String { get }
var engineMode: String { get }
var credentialSource: CredentialSource { get }
var polishIntensity: PolishIntensity { get }
var aiResponseLength: AIResponseLength { get }
var llmThinkingEnabled: Bool { get }
@@ -39,5 +40,14 @@ public protocol ConfigurationStore: Sendable {
/// Provider-specific ASR caches (e.g. Alibaba Fun-ASR vocabulary IDs).
var cloudASRPersistence: UserDefaults { get }
func makeClient() -> LLMClient
func makeClient(taskKind: ManagedGatewayTaskKind?) -> LLMClient
}
public extension ConfigurationStore {
/// Existing stores and test doubles remain BYOK unless they opt in.
var credentialSource: CredentialSource { .byok }
func makeClient() -> LLMClient {
makeClient(taskKind: nil)
}
}
@@ -16,6 +16,7 @@ public struct LiveConfigurationSnapshot {
public let asrApiKey: String
public let asrModel: String
public let engineMode: String
public let credentialSource: CredentialSource
public let polishIntensity: PolishIntensity
public let aiResponseLength: AIResponseLength
public let llmThinkingEnabled: Bool
@@ -35,6 +36,7 @@ public struct LiveConfigurationSnapshot {
asrApiKey: String,
asrModel: String,
engineMode: String,
credentialSource: CredentialSource = .byok,
polishIntensity: PolishIntensity,
aiResponseLength: AIResponseLength = .default,
llmThinkingEnabled: Bool,
@@ -53,6 +55,7 @@ public struct LiveConfigurationSnapshot {
self.asrApiKey = asrApiKey
self.asrModel = asrModel
self.engineMode = engineMode
self.credentialSource = credentialSource
self.polishIntensity = polishIntensity
self.aiResponseLength = aiResponseLength
self.llmThinkingEnabled = llmThinkingEnabled
@@ -75,6 +78,7 @@ public struct LiveConfigurationSnapshot {
asrApiKey: config.asrApiKey,
asrModel: config.asrModel,
engineMode: config.engineMode,
credentialSource: config.credentialSource,
polishIntensity: config.polishIntensity,
aiResponseLength: config.aiResponseLength,
llmThinkingEnabled: config.llmThinkingEnabled,
@@ -110,6 +114,7 @@ public struct LiveConfigurationStore: ConfigurationStore, @unchecked Sendable {
public var asrApiKey: String { snapshot.asrApiKey }
public var asrModel: String { snapshot.asrModel }
public var engineMode: String { snapshot.engineMode }
public var credentialSource: CredentialSource { snapshot.credentialSource }
public var polishIntensity: PolishIntensity { snapshot.polishIntensity }
public var aiResponseLength: AIResponseLength { snapshot.aiResponseLength }
public var llmThinkingEnabled: Bool { snapshot.llmThinkingEnabled }
@@ -119,8 +124,15 @@ public struct LiveConfigurationStore: ConfigurationStore, @unchecked Sendable {
public var detectedAppContext: (context: AppContext, observedAt: Date)? { snapshot.detectedAppContext }
public var cloudASRPersistence: UserDefaults { snapshot.cloudASRPersistence }
public func makeClient() -> LLMClient {
LLMClientFactory.make(
public func makeClient(taskKind: ManagedGatewayTaskKind?) -> LLMClient {
if credentialSource == .managed {
return ManagedLLMClient(
capability: .polish,
taskKind: taskKind,
grants: GatewayGrantCoordinator()
)
}
return LLMClientFactory.make(
providerId: providerId,
baseURL: baseURL,
apiKey: apiKey,
+59 -59
View File
@@ -17,27 +17,27 @@ import SwiftUI
/// `Palette` static API so existing call sites (`Palette.background` etc.)
/// still compile and resolve through the legacy static accessors below.
public struct ThemePalette: Sendable, Equatable {
public let background: Color
public let surface: Color
public let background: Color
public let surface: Color
public let surfaceElevated: Color
public let surfaceMuted: Color
public let surfaceMuted: Color
public let accent: Color
public let accent: Color
public let accentMuted: Color
public let accentGlow: Color
public let accentGlow: Color
/// Distinguishes AI listening / generation from ordinary dictation.
public let aiTeal: Color
public let aiTeal: Color
public let danger: Color
public let danger: Color
public let success: Color
public let warning: Color
public let textPrimary: Color
public let textPrimary: Color
public let textSecondary: Color
public let textTertiary: Color
public let textOnAccent: Color
public let textTertiary: Color
public let textOnAccent: Color
public let divider: Color
public let divider: Color
public let dividerStrong: Color
public let recordRed: Color
@@ -82,48 +82,48 @@ public enum Palette {
/// getting the dark value (important for the keyboard extension, which
/// deliberately stays dark regardless of system appearance).
public static let dark = ThemePalette(
background: background,
surface: surface,
background: background,
surface: surface,
surfaceElevated: surfaceElevated,
surfaceMuted: surfaceMuted,
accent: accent,
accentMuted: accentMuted,
accentGlow: accentGlow,
aiTeal: aiTeal,
danger: danger,
success: success,
warning: warning,
textPrimary: textPrimary,
textSecondary: textSecondary,
textTertiary: textTertiary,
textOnAccent: textOnAccent,
divider: divider,
dividerStrong: dividerStrong,
recordRed: recordRed,
recordBlue: recordBlue
surfaceMuted: surfaceMuted,
accent: accent,
accentMuted: accentMuted,
accentGlow: accentGlow,
aiTeal: aiTeal,
danger: danger,
success: success,
warning: warning,
textPrimary: textPrimary,
textSecondary: textSecondary,
textTertiary: textTertiary,
textOnAccent: textOnAccent,
divider: divider,
dividerStrong: dividerStrong,
recordRed: recordRed,
recordBlue: recordBlue
)
/// Light palette warm gray backgrounds for daytime use.
public static let light = ThemePalette(
background: Color(red: 0.949, green: 0.945, blue: 0.933), // #F2F1EE warm gray
surface: Color(red: 0.988, green: 0.984, blue: 0.976), // #FCFBF9
background: Color(red: 0.949, green: 0.945, blue: 0.933), // #F2F1EE warm gray
surface: Color(red: 0.988, green: 0.984, blue: 0.976), // #FCFBF9
surfaceElevated: Color(red: 0.922, green: 0.918, blue: 0.906), // #EBEAE7
surfaceMuted: Color(red: 0.933, green: 0.929, blue: 0.918), // #EEEDE9
accent: Color(red: 0.227, green: 0.627, blue: 0.353), // #3AA05A
accentMuted: Color(red: 0.227, green: 0.627, blue: 0.353).opacity(0.14),
accentGlow: Color(red: 0.227, green: 0.627, blue: 0.353).opacity(0.32),
aiTeal: Color(red: 0.169, green: 0.686, blue: 0.643), // #2BAFA4
danger: Color(red: 1.000, green: 0.231, blue: 0.188), // #FF3B30
success: Color(red: 0.227, green: 0.627, blue: 0.353), // same as accent
warning: Color(red: 1.000, green: 0.620, blue: 0.094), // #FF9E18
textPrimary: Color(red: 0.067, green: 0.067, blue: 0.094), // #111118
textSecondary: Color(red: 0.392, green: 0.392, blue: 0.435), // #64646F
textTertiary: Color(red: 0.557, green: 0.557, blue: 0.604), // #8E8E9A
textOnAccent: Color.white,
divider: Color.black.opacity(0.06),
dividerStrong: Color.black.opacity(0.10),
recordRed: Color(red: 1.000, green: 0.231, blue: 0.188), // #FF3B30
recordBlue: Color(red: 0.000, green: 0.478, blue: 1.000) // #007AFF
surfaceMuted: Color(red: 0.933, green: 0.929, blue: 0.918), // #EEEDE9
accent: Color(red: 0.227, green: 0.627, blue: 0.353), // #3AA05A
accentMuted: Color(red: 0.227, green: 0.627, blue: 0.353).opacity(0.14),
accentGlow: Color(red: 0.227, green: 0.627, blue: 0.353).opacity(0.32),
aiTeal: Color(red: 0.169, green: 0.686, blue: 0.643), // #2BAFA4
danger: Color(red: 1.000, green: 0.231, blue: 0.188), // #FF3B30
success: Color(red: 0.227, green: 0.627, blue: 0.353), // same as accent
warning: Color(red: 1.000, green: 0.620, blue: 0.094), // #FF9E18
textPrimary: Color(red: 0.067, green: 0.067, blue: 0.094), // #111118
textSecondary: Color(red: 0.392, green: 0.392, blue: 0.435), // #64646F
textTertiary: Color(red: 0.557, green: 0.557, blue: 0.604), // #8E8E9A
textOnAccent: Color.white,
divider: Color.black.opacity(0.06),
dividerStrong: Color.black.opacity(0.10),
recordRed: Color(red: 1.000, green: 0.231, blue: 0.188), // #FF3B30
recordBlue: Color(red: 0.000, green: 0.478, blue: 1.000) // #007AFF
)
}
@@ -147,13 +147,13 @@ public extension EnvironmentValues {
// MARK: - Spacing scale (4 pt grid)
public enum Spacing {
public static let xxs: CGFloat = 4
public static let xs: CGFloat = 8
public static let sm: CGFloat = 12
public static let md: CGFloat = 16
public static let lg: CGFloat = 20
public static let xl: CGFloat = 24
public static let xxl: CGFloat = 32
public static let xxs: CGFloat = 4
public static let xs: CGFloat = 8
public static let sm: CGFloat = 12
public static let md: CGFloat = 16
public static let lg: CGFloat = 20
public static let xl: CGFloat = 24
public static let xxl: CGFloat = 32
public static let xxxl: CGFloat = 40
public static let hero: CGFloat = 48
}
@@ -161,12 +161,12 @@ public enum Spacing {
// MARK: - Corner radius scale
public enum Radius {
public static let small: CGFloat = 8
public static let small: CGFloat = 8
public static let medium: CGFloat = 12
public static let large: CGFloat = 16
public static let xl: CGFloat = 20
public static let xxl: CGFloat = 24
public static let pill: CGFloat = 999
public static let large: CGFloat = 16
public static let xl: CGFloat = 20
public static let xxl: CGFloat = 24
public static let pill: CGFloat = 999
}
// MARK: - Typography
@@ -0,0 +1,256 @@
// GatewayGrantCoordinator.swift
// OSGKeyboard · Shared
//
// Creates and rotates scope-limited gateway grants. Refresh rotation is merged
// inside this actor so concurrent extension requests never replay an old token.
import CryptoKit
import Foundation
public actor GatewayGrantCoordinator {
public static let defaultBaseURL = URL(string: "https://account.osglab.com")!
private let baseURL: URL
private let store: any GatewayGrantCredentialStore
private let session: URLSession
private let now: @Sendable () -> Date
private var refreshTask: Task<ManagedGatewayGrantCredentials, Error>?
public init(
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
store: any GatewayGrantCredentialStore = GatewayGrantKeychainStore(),
session: URLSession = .shared,
now: @escaping @Sendable () -> Date = Date.init
) {
self.baseURL = baseURL
self.store = store
self.session = session
self.now = now
}
/// Host-only integration point. The account access token authorizes grant
/// creation but is used only for this request and is never persisted here.
@discardableResult
public func createGrant(
accountAccessToken: String,
scopes: Set<ManagedGatewayCapability>,
lifetimeSeconds: Int? = nil,
idempotencyKey: String = UUID().uuidString
) async throws -> ManagedGatewayGrantCredentials {
guard !accountAccessToken.isEmpty else { throw ManagedGatewayError.invalidGrant }
guard !scopes.isEmpty else {
throw ManagedGatewayError.server(
code: "invalid_request",
status: 400,
requestId: nil
)
}
struct Body: Encodable {
let scopes: [ManagedGatewayCapability]
let lifetimeSeconds: Int?
}
var request = URLRequest(url: endpoint("v1/gateway/grants"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(accountAccessToken)", forHTTPHeaderField: "Authorization")
request.setValue(idempotencyKey, forHTTPHeaderField: "Idempotency-Key")
request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-ID")
request.httpBody = try JSONEncoder().encode(
Body(scopes: scopes.sorted { $0.rawValue < $1.rawValue }, lifetimeSeconds: lifetimeSeconds)
)
let credentials = try await sendGrantRequest(request)
guard credentials.scopes == scopes else {
throw ManagedGatewayError.invalidGrant
}
try await store.save(credentials)
return credentials
}
public func accessToken(
for scope: ManagedGatewayCapability,
forceRefresh: Bool = false
) async throws -> String {
guard let credentials = try await store.load() else {
throw ManagedGatewayError.missingGrant
}
guard credentials.scopes.contains(scope) else {
throw ManagedGatewayError.scopeNotGranted(scope)
}
if !forceRefresh, credentials.hasUsableAccessToken(for: scope, at: now()) {
return credentials.accessToken
}
guard credentials.hasUsableRefreshToken(at: now()) else {
try? await store.delete()
throw ManagedGatewayError.invalidGrant
}
return try await refresh(credentials).accessToken
}
public func clearGrant() async throws {
refreshTask?.cancel()
refreshTask = nil
try await store.delete()
}
private func refresh(
_ credentials: ManagedGatewayGrantCredentials
) async throws -> ManagedGatewayGrantCredentials {
if let refreshTask {
return try await refreshTask.value
}
let task = Task {
try await requestRefresh(using: credentials)
}
refreshTask = task
do {
let refreshed = try await task.value
refreshTask = nil
return refreshed
} catch {
refreshTask = nil
throw error
}
}
private func requestRefresh(
using credentials: ManagedGatewayGrantCredentials
) async throws -> ManagedGatewayGrantCredentials {
struct Body: Encodable {
let refreshToken: String
}
var request = URLRequest(url: endpoint("v1/gateway/grants/refresh"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(
Self.refreshTokenIdempotencyKey(credentials.refreshToken),
forHTTPHeaderField: "Idempotency-Key"
)
request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-ID")
request.httpBody = try JSONEncoder().encode(Body(refreshToken: credentials.refreshToken))
do {
let refreshed = try await sendGrantRequest(request)
guard refreshed.grantId == credentials.grantId,
refreshed.scopes == credentials.scopes else {
try? await store.delete()
throw ManagedGatewayError.invalidGrant
}
try await store.save(refreshed)
return refreshed
} catch let error as ManagedGatewayError where error == .invalidGrant {
try? await store.delete()
throw error
}
}
private func sendGrantRequest(
_ request: URLRequest
) async throws -> ManagedGatewayGrantCredentials {
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw LLMError.transport("non-HTTP response")
}
guard (200..<300).contains(http.statusCode) else {
throw ManagedGatewayHTTP.error(
data: data,
status: http.statusCode,
requestId: http.value(forHTTPHeaderField: "X-Request-ID")
)
}
do {
return try Self.gatewayDecoder()
.decode(ManagedGatewayGrantTokenResponse.self, from: data)
.credentials(receivedAt: now())
} catch {
throw LLMError.decoding(String(describing: error))
}
} catch is CancellationError {
throw LLMError.cancelled
} catch let error as URLError where error.code == .cancelled {
throw LLMError.cancelled
} catch let error as URLError where error.code == .timedOut {
throw ManagedGatewayError.timeout
} catch let error as ManagedGatewayError {
throw error
} catch let error as LLMError {
throw error
} catch {
throw LLMError.transport(String(describing: error))
}
}
private func endpoint(_ path: String) -> URL {
baseURL.appending(path: path)
}
static func refreshTokenIdempotencyKey(_ refreshToken: String) -> String {
let digest = SHA256.hash(data: Data(refreshToken.utf8))
return "gateway-refresh-v1-" + digest.map { String(format: "%02x", $0) }.joined()
}
private static func gatewayDecoder() -> JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom { decoder in
let value = try decoder.singleValueContainer().decode(String.self)
let fractional = ISO8601DateFormatter()
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = fractional.date(from: value) {
return date
}
let standard = ISO8601DateFormatter()
standard.formatOptions = [.withInternetDateTime]
guard let date = standard.date(from: value) else {
throw DecodingError.dataCorruptedError(
in: try decoder.singleValueContainer(),
debugDescription: "Invalid ISO-8601 date"
)
}
return date
}
return decoder
}
}
enum ManagedGatewayHTTP {
static func error(
data: Data,
status: Int,
requestId: String?
) -> ManagedGatewayError {
let decoded = decodeError(from: data)
let code = decoded?.code ?? HTTPURLResponse.localizedString(forStatusCode: status)
let resolvedRequestId = decoded?.requestId ?? requestId
switch code.lowercased() {
case "insufficient_credits", "insufficient_balance", "credit_balance_insufficient":
return .insufficientCredits
case "unauthorized", "invalid_gateway_refresh", "gateway_grant_denied", "invalid_grant":
return .invalidGrant
default:
return .server(code: code, status: status, requestId: resolvedRequestId)
}
}
static func decodeError(from data: Data) -> ManagedGatewayErrorResponse? {
if let direct = try? JSONDecoder().decode(ManagedGatewayErrorResponse.self, from: data) {
return direct
}
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let nested = object["error"] as? [String: Any],
let code = nested["code"] as? String,
let message = nested["message"] as? String else {
return nil
}
return ManagedGatewayErrorResponse(
code: code,
message: message,
requestId: nested["requestId"] as? String ?? ""
)
}
}
@@ -0,0 +1,106 @@
// GatewayGrantCredentialStore.swift
// OSGKeyboard · Shared
//
// Shared Keychain storage dedicated to scope-limited gateway grants. Account
// session credentials intentionally never enter this service or access group item.
import Foundation
import Security
public protocol GatewayGrantCredentialStore: Sendable {
func load() async throws -> ManagedGatewayGrantCredentials?
func save(_ credentials: ManagedGatewayGrantCredentials) async throws
func delete() async throws
}
public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, @unchecked Sendable {
public enum StoreError: Error, Equatable, Sendable {
case unexpectedStatus(OSStatus)
case invalidStoredValue
}
private static let service = "com.osgkeyboard.gateway-grant"
private static let account = "scope-limited.active"
public init() {}
public func load() async throws -> ManagedGatewayGrantCredentials? {
var query = Self.baseQuery
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
switch status {
case errSecSuccess:
guard let data = result as? Data,
let value = try? Self.decoder.decode(
ManagedGatewayGrantCredentials.self,
from: data
) else {
throw StoreError.invalidStoredValue
}
return value
case errSecItemNotFound:
return nil
default:
throw StoreError.unexpectedStatus(status)
}
}
public func save(_ credentials: ManagedGatewayGrantCredentials) async throws {
let data = try Self.encoder.encode(credentials)
let updateStatus = SecItemUpdate(
Self.baseQuery as CFDictionary,
[kSecValueData as String: data] as CFDictionary
)
switch updateStatus {
case errSecSuccess:
return
case errSecItemNotFound:
var query = Self.baseQuery
query[kSecValueData as String] = data
// The extension can refresh after reboot without making account
// credentials readable; this item contains only the limited grant.
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let addStatus = SecItemAdd(query as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw StoreError.unexpectedStatus(addStatus)
}
default:
throw StoreError.unexpectedStatus(updateStatus)
}
}
public func delete() async throws {
let status = SecItemDelete(Self.baseQuery as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw StoreError.unexpectedStatus(status)
}
}
private static var baseQuery: [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: kCFBooleanFalse!
]
#if os(macOS)
query[kSecUseDataProtectionKeychain as String] = true
#endif
return query
}
private static var encoder: JSONEncoder {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
return encoder
}
private static var decoder: JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}
}
@@ -0,0 +1,146 @@
// ManagedGatewayModels.swift
// OSGKeyboard · Shared
//
// Scope-limited credentials and transport models for the managed gateway.
import Foundation
public enum ManagedGatewayCapability: String, Codable, CaseIterable, Sendable {
case polish
case assistant = "ai"
case agent
case asr
}
/// Stable task contract shared with the account gateway. The app identifies
/// user intent; the server remains authoritative for model and feature policy.
public enum ManagedGatewayTaskKind: String, Codable, CaseIterable, Sendable {
case dictationPolish = "dictation_polish"
case translation
case editLastInput = "edit_last_input"
case aiQuestion = "ai_question"
case clipboardTransform = "clipboard_transform"
case customSkill = "custom_skill"
case agentPlanning = "agent_planning"
}
public struct ManagedGatewayGrantCredentials: Codable, Equatable, Sendable {
public static let maximumAccessLifetime: TimeInterval = 5 * 60
public let grantId: String
public let scopes: Set<ManagedGatewayCapability>
public let accessToken: String
public let accessExpiresAt: Date
public let refreshToken: String
public let refreshExpiresAt: Date
public let receivedAt: Date
public init(
grantId: String,
scopes: Set<ManagedGatewayCapability>,
accessToken: String,
accessExpiresAt: Date,
refreshToken: String,
refreshExpiresAt: Date,
receivedAt: Date = Date()
) {
self.grantId = grantId
self.scopes = scopes
self.accessToken = accessToken
self.accessExpiresAt = accessExpiresAt
self.refreshToken = refreshToken
self.refreshExpiresAt = refreshExpiresAt
self.receivedAt = receivedAt
}
/// Never trust an unexpectedly long access expiry. The gateway contract
/// deliberately limits extension-readable bearer credentials to five minutes.
public var effectiveAccessExpiresAt: Date {
min(accessExpiresAt, receivedAt.addingTimeInterval(Self.maximumAccessLifetime))
}
public func hasUsableAccessToken(
for scope: ManagedGatewayCapability,
at now: Date = Date(),
refreshLeeway: TimeInterval = 30
) -> Bool {
scopes.contains(scope)
&& !accessToken.isEmpty
&& effectiveAccessExpiresAt.timeIntervalSince(now) > refreshLeeway
}
public func hasUsableRefreshToken(at now: Date = Date()) -> Bool {
!refreshToken.isEmpty && refreshExpiresAt > now
}
}
public enum ManagedGatewayError: Error, LocalizedError, Equatable, Sendable {
case missingGrant
case scopeNotGranted(ManagedGatewayCapability)
case invalidGrant
case insufficientCredits
case timeout
case server(code: String, status: Int, requestId: String?)
public var errorDescription: String? {
switch self {
case .missingGrant:
return SharedL10n.string("managed.error.grantUnavailable")
case .scopeNotGranted(let scope):
return SharedL10n.format(
"managed.error.scopeNotGranted",
language: nil,
scope.rawValue
)
case .invalidGrant:
return SharedL10n.string("managed.error.grantRejected")
case .insufficientCredits:
return SharedL10n.string("managed.error.insufficientCredits")
case .timeout:
return SharedL10n.string("managed.error.timeout")
case .server(let code, let status, _):
return SharedL10n.format(
"managed.error.server",
language: nil,
code,
status
)
}
}
}
struct ManagedGatewayGrantTokenResponse: Decodable {
let grantId: String
let scopes: Set<ManagedGatewayCapability>
let accessToken: String
let accessExpiresAt: Date
let refreshToken: String
let refreshExpiresAt: Date
func credentials(receivedAt: Date) -> ManagedGatewayGrantCredentials {
ManagedGatewayGrantCredentials(
grantId: grantId,
scopes: scopes,
accessToken: accessToken,
accessExpiresAt: accessExpiresAt,
refreshToken: refreshToken,
refreshExpiresAt: refreshExpiresAt,
receivedAt: receivedAt
)
}
}
struct ManagedGatewayErrorResponse: Decodable, Sendable {
let code: String
let message: String
let requestId: String
}
struct ManagedGatewayTextRequest: Encodable, Sendable {
let input: String
let context: String?
let maxOutputTokens: Int
let temperature: Double
let stream: Bool
let taskKind: ManagedGatewayTaskKind
}
@@ -0,0 +1,16 @@
// ManagedGatewayScopePolicy.swift
// OSGKeyboard · Shared
//
// Computes the smallest grant needed by the currently enabled managed paths.
import Foundation
public enum ManagedGatewayScopePolicy {
public static func scopes(engineMode: String) -> Set<ManagedGatewayCapability> {
var scopes: Set<ManagedGatewayCapability> = [.polish, .assistant, .agent]
if engineMode == "cloud" {
scopes.insert(.asr)
}
return scopes
}
}
@@ -0,0 +1,549 @@
// ManagedLLMClient.swift
// OSGKeyboard · Shared
//
// LLMClient implementation for scope-limited managed polish, AI and agent calls.
import Foundation
public struct ManagedLLMClient: LLMClient {
public enum Capability: String, Sendable {
case polish
case assistant = "ai"
case agent
var grantScope: ManagedGatewayCapability {
switch self {
case .polish: .polish
case .assistant: .assistant
case .agent: .agent
}
}
var defaultTaskKind: ManagedGatewayTaskKind {
switch self {
case .polish: .dictationPolish
case .assistant: .aiQuestion
case .agent: .agentPlanning
}
}
}
private struct Attempt {
let input: String
let context: String?
let timeout: TimeInterval?
let options: LLMGenerationOptions
let requestId: String
let forceRefresh: Bool
func forcingRefresh() -> Self {
Self(
input: input,
context: context,
timeout: timeout,
options: options,
requestId: requestId,
forceRefresh: true
)
}
}
public let capability: Capability
public let taskKind: ManagedGatewayTaskKind
public let requestTimeout: TimeInterval
private let baseURL: URL
private let grants: GatewayGrantCoordinator
private let session: URLSession
private let requestId: @Sendable () -> String
public init(
capability: Capability,
taskKind: ManagedGatewayTaskKind? = nil,
grants: GatewayGrantCoordinator,
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
session: URLSession = .shared,
requestTimeout: TimeInterval = 15,
requestId: @escaping @Sendable () -> String = { UUID().uuidString }
) {
self.capability = capability
self.taskKind = taskKind ?? capability.defaultTaskKind
self.grants = grants
self.baseURL = baseURL
self.session = session
self.requestTimeout = requestTimeout
self.requestId = requestId
}
public func polish(
_ text: String,
systemPrompt: String,
timeout: TimeInterval?
) async throws -> String {
try await polish(
text,
systemPrompt: systemPrompt,
timeout: timeout,
options: .polishDefault
)
}
public func polish(
_ text: String,
systemPrompt: String,
timeout: TimeInterval?,
options: LLMGenerationOptions
) async throws -> String {
try await executeBuffered(
input: text,
context: systemPrompt.nilIfEmpty,
timeout: timeout,
options: options
)
}
public func complete(
messages: [LLMRequest.Message],
timeout: TimeInterval?,
options: LLMGenerationOptions
) async throws -> String {
let payload = Self.payload(from: messages)
return try await executeBuffered(
input: payload.input,
context: payload.context,
timeout: timeout,
options: options
)
}
public func completeStreaming(
messages: [LLMRequest.Message],
timeout: TimeInterval?,
options: LLMGenerationOptions
) -> AsyncThrowingStream<LLMStreamEvent, Error> {
AsyncThrowingStream { continuation in
let task = Task {
do {
let payload = Self.payload(from: messages)
let logicalRequestId = requestId()
let attempt = Attempt(
input: payload.input,
context: payload.context,
timeout: timeout,
options: options,
requestId: logicalRequestId,
forceRefresh: false
)
var emittedVisibleText = false
do {
try await streamAttempt(attempt) { chunk in
emittedVisibleText = true
continuation.yield(.delta(chunk))
}
} catch ManagedGatewayError.invalidGrant where !emittedVisibleText {
try await streamAttempt(attempt.forcingRefresh()) { chunk in
emittedVisibleText = true
continuation.yield(.delta(chunk))
}
}
continuation.finish()
} catch is CancellationError {
continuation.finish(throwing: LLMError.cancelled)
} catch let error as URLError where error.code == .cancelled {
continuation.finish(throwing: LLMError.cancelled)
} catch let error as URLError where error.code == .timedOut {
continuation.finish(throwing: ManagedGatewayError.timeout)
} catch let error as LLMError {
continuation.finish(throwing: error)
} catch let error as ManagedGatewayError {
continuation.finish(throwing: error)
} catch {
continuation.finish(throwing: LLMError.transport(String(describing: error)))
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
private func executeBuffered(
input: String,
context: String?,
timeout: TimeInterval?,
options: LLMGenerationOptions
) async throws -> String {
let logicalRequestId = requestId()
let attempt = Attempt(
input: input,
context: context,
timeout: timeout,
options: options,
requestId: logicalRequestId,
forceRefresh: false
)
do {
return try await bufferedAttempt(attempt)
} catch ManagedGatewayError.invalidGrant {
do {
return try await bufferedAttempt(attempt.forcingRefresh())
} catch ManagedGatewayError.invalidGrant {
try? await grants.clearGrant()
throw ManagedGatewayError.invalidGrant
}
}
}
private func bufferedAttempt(_ attempt: Attempt) async throws -> String {
let request = try await makeRequest(
attempt,
stream: false
)
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw LLMError.transport("non-HTTP response")
}
guard (200..<300).contains(http.statusCode) else {
throw ManagedGatewayHTTP.error(
data: data,
status: http.statusCode,
requestId: attempt.requestId
)
}
return try Self.responseText(from: data)
} catch is CancellationError {
throw LLMError.cancelled
} catch let error as URLError where error.code == .cancelled {
throw LLMError.cancelled
} catch let error as URLError where error.code == .timedOut {
throw ManagedGatewayError.timeout
} catch let error as ManagedGatewayError {
throw error
} catch let error as LLMError {
throw error
} catch {
throw LLMError.transport(String(describing: error))
}
}
private func streamAttempt(
_ attempt: Attempt,
onDelta: @escaping (String) -> Void
) async throws {
guard capability != .agent else {
// The server validates agent output as one structured response.
let text = try await bufferedAttempt(attempt)
if !text.isEmpty {
onDelta(text)
}
return
}
let request = try await makeRequest(
attempt,
stream: true
)
for try await payload in ManagedGatewayStreamTransport.payloads(
session: session,
request: request,
requestId: attempt.requestId
) {
try Task.checkCancellation()
if let error = Self.streamingError(from: payload, requestId: attempt.requestId) {
throw error
}
if let delta = Self.streamingDelta(from: payload), !delta.isEmpty {
onDelta(delta)
}
}
}
private func makeRequest(_ attempt: Attempt, stream: Bool) async throws -> URLRequest {
let trimmedInput = attempt.input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedInput.isEmpty, trimmedInput.count <= 32_000 else {
throw ManagedGatewayError.server(
code: "invalid_request",
status: 400,
requestId: attempt.requestId
)
}
let boundedContext = attempt.context?
.trimmingCharacters(in: .whitespacesAndNewlines)
.nilIfEmpty
guard (boundedContext?.count ?? 0) <= 32_000 else {
throw ManagedGatewayError.server(
code: "invalid_request",
status: 400,
requestId: attempt.requestId
)
}
let token = try await grants.accessToken(
for: capability.grantScope,
forceRefresh: attempt.forceRefresh
)
let body = ManagedGatewayTextRequest(
input: trimmedInput,
context: boundedContext,
maxOutputTokens: min(max(attempt.options.maxTokens ?? 512, 1), 4_096),
temperature: min(max(attempt.options.temperature ?? 0.2, 0), 1),
stream: stream,
taskKind: taskKind
)
var request = URLRequest(
url: baseURL.appending(path: "v1/gateway/llm/\(capability.rawValue)")
)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(stream ? "text/event-stream" : "application/json", forHTTPHeaderField: "Accept")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue(attempt.requestId, forHTTPHeaderField: "X-Request-ID")
request.timeoutInterval = attempt.timeout ?? requestTimeout
request.httpBody = try JSONEncoder().encode(body)
return request
}
static func payload(
from messages: [LLMRequest.Message]
) -> (input: String, context: String?) {
guard let inputIndex = messages.lastIndex(where: { $0.role == "user" }) else {
return ("", Self.contextText(from: messages))
}
let input = messages[inputIndex].content
var contextMessages = messages
contextMessages.remove(at: inputIndex)
return (input, Self.contextText(from: contextMessages))
}
private static func contextText(from messages: [LLMRequest.Message]) -> String? {
messages
.filter { !$0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
.map { "\($0.role):\n\($0.content)" }
.joined(separator: "\n\n")
.nilIfEmpty
}
static func responseText(from data: Data) throws -> String {
let raw = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !raw.isEmpty else { throw LLMError.decoding("empty managed gateway response") }
guard let json = try? JSONSerialization.jsonObject(with: data) else {
return raw
}
return extractedText(from: json)?.trimmingCharacters(in: .whitespacesAndNewlines)
.nilIfEmpty ?? raw
}
private static func extractedText(from value: Any) -> String? {
if let string = value as? String {
return string
}
if let array = value as? [Any] {
let values = array.compactMap(extractedText(from:))
return values.isEmpty ? nil : values.joined()
}
guard let object = value as? [String: Any] else { return nil }
for key in ["output_text", "text"] {
if let text = object[key] as? String, !text.isEmpty {
return text
}
}
if let content = object["content"] {
if let text = content as? String, !text.isEmpty {
return text
}
if let extracted = extractedText(from: content), !extracted.isEmpty {
return extracted
}
}
if let message = object["message"] as? [String: Any],
let extracted = extractedText(from: message) {
return extracted
}
if let choices = object["choices"] as? [[String: Any]],
let first = choices.first {
if let message = first["message"] as? [String: Any],
let extracted = extractedText(from: message) {
return extracted
}
if let text = first["text"] as? String {
return text
}
}
if let data = object["data"], let extracted = extractedText(from: data) {
return extracted
}
if let output = object["output"], let extracted = extractedText(from: output) {
return extracted
}
return nil
}
static func streamingDelta(from data: Data) -> String? {
if let value = LLMStreamDeltaParser.responsesOutputTextDelta(from: data)
?? LLMStreamDeltaParser.chatCompletionsDelta(from: data)
?? LLMStreamDeltaParser.anthropicTextDelta(from: data) {
return value
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return String(data: data, encoding: .utf8)
}
for key in ["delta", "text", "content"] {
if let value = json[key] as? String, !value.isEmpty {
return value
}
}
return nil
}
private static func streamingError(
from data: Data,
requestId: String
) -> ManagedGatewayError? {
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let code = object["code"] as? String,
object["message"] != nil || code.hasSuffix("_error") else {
return nil
}
if ["insufficient_credits", "insufficient_balance"].contains(code) {
return .insufficientCredits
}
if ["unauthorized", "gateway_grant_denied", "invalid_grant"].contains(code) {
return .invalidGrant
}
return .server(code: code, status: 200, requestId: requestId)
}
}
public enum ManagedGatewayLLMClientFactory {
public static func polish(
taskKind: ManagedGatewayTaskKind = .dictationPolish,
grants: GatewayGrantCoordinator,
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
session: URLSession = .shared
) -> any LLMClient {
ManagedLLMClient(
capability: .polish,
taskKind: taskKind,
grants: grants,
baseURL: baseURL,
session: session
)
}
public static func ai(
taskKind: ManagedGatewayTaskKind = .aiQuestion,
grants: GatewayGrantCoordinator,
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
session: URLSession = .shared
) -> any LLMClient {
ManagedLLMClient(
capability: .assistant,
taskKind: taskKind,
grants: grants,
baseURL: baseURL,
session: session
)
}
public static func agent(
taskKind: ManagedGatewayTaskKind = .agentPlanning,
grants: GatewayGrantCoordinator,
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
session: URLSession = .shared
) -> any LLMClient {
ManagedLLMClient(
capability: .agent,
taskKind: taskKind,
grants: grants,
baseURL: baseURL,
session: session
)
}
}
private extension String {
var nilIfEmpty: String? {
isEmpty ? nil : self
}
}
enum ManagedGatewayStreamTransport {
static func payloads(
session: URLSession,
request: URLRequest,
requestId: String
) -> AsyncThrowingStream<Data, Error> {
AsyncThrowingStream { continuation in
let task = Task {
do {
let (bytes, response) = try await session.bytes(for: request)
guard let http = response as? HTTPURLResponse else {
throw LLMError.transport("non-HTTP response")
}
guard (200..<300).contains(http.statusCode) else {
var body = Data()
for try await byte in bytes {
body.append(byte)
}
throw ManagedGatewayHTTP.error(
data: body,
status: http.statusCode,
requestId: requestId
)
}
let isEventStream = http.value(forHTTPHeaderField: "Content-Type")?
.lowercased()
.contains("text/event-stream") == true
if !isEventStream {
var body = Data()
for try await byte in bytes {
try Task.checkCancellation()
body.append(byte)
}
if !body.isEmpty {
continuation.yield(body)
}
continuation.finish()
return
}
var line = Data()
for try await byte in bytes {
try Task.checkCancellation()
if byte == UInt8(ascii: "\n") {
yieldSSELine(line, to: continuation)
line.removeAll(keepingCapacity: true)
} else if byte != UInt8(ascii: "\r") {
line.append(byte)
}
}
yieldSSELine(line, to: continuation)
continuation.finish()
} catch is CancellationError {
continuation.finish(throwing: LLMError.cancelled)
} catch let error as URLError where error.code == .cancelled {
continuation.finish(throwing: LLMError.cancelled)
} catch let error as URLError where error.code == .timedOut {
continuation.finish(throwing: ManagedGatewayError.timeout)
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
private static func yieldSSELine(
_ line: Data,
to continuation: AsyncThrowingStream<Data, Error>.Continuation
) {
guard let payload = LLMStreamTransport.sseDataPayload(fromLineBytes: line),
payload != Data("[DONE]".utf8) else {
return
}
continuation.yield(payload)
}
}
@@ -13,7 +13,7 @@ public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
public static let defaultEnabledIDs = [
AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.summarizeID,
AIClipboardSkillCatalog.translateID,
AIClipboardSkillCatalog.translateID
]
/// Keyboard chip order. Unknown / unconfirmed IDs are dropped on sanitize.
+1 -1
View File
@@ -110,7 +110,7 @@ public enum AIUserSkillLimits {
"number",
"at",
"tray.fill",
"quote.bubble.fill",
"quote.bubble.fill"
]
}
@@ -28,6 +28,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public static let modeId = "config.modeId"
public static let localeId = "config.localeId"
public static let engineMode = "config.engineMode"
/// Credential ownership is independent from local/cloud ASR selection.
public static let credentialSource = "config.credentialSource"
public static let hasCompletedOnboarding = "config.hasCompletedOnboarding"
public static let onboardingPage = "config.onboardingPage"
public static let hasAcknowledgedCloudSharing = "config.hasAcknowledgedCloudSharing"
@@ -87,6 +89,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public var modeId: String
public var localeId: String
public var engineMode: String
public var credentialSource: CredentialSource
public var hasCompletedOnboarding: Bool
public var onboardingPage: Int
public var hasAcknowledgedCloudSharing: Bool
@@ -146,11 +149,13 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public var isCloudLLMKeyMissing: Bool {
guard engineMode == "cloud" else { return false }
guard credentialSource == .byok else { return false }
return apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
public var isCloudASRKeyMissing: Bool {
guard engineMode == "cloud" else { return false }
guard credentialSource == .byok else { return false }
let key = asrApiKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return true }
// Volcengine may store auth_mode JSON before credentials are filled.
@@ -161,7 +166,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
}
public var isPolishKeyMissing: Bool {
apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
guard credentialSource == .byok else { return false }
return apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
public var isCloudAPIKeyMissingForVoiceInput: Bool {
@@ -187,8 +193,15 @@ public struct AppGroupConfiguration: Sendable, Equatable {
)
}
public func makeClient() -> LLMClient {
OpenAICompatibleClient(
public func makeClient(taskKind: ManagedGatewayTaskKind? = nil) -> LLMClient {
if credentialSource == .managed {
return ManagedLLMClient(
capability: .polish,
taskKind: taskKind,
grants: GatewayGrantCoordinator()
)
}
return OpenAICompatibleClient(
baseURL: baseURL,
apiKey: apiKey,
model: model,
@@ -255,6 +268,9 @@ public struct AppGroupConfiguration: Sendable, Equatable {
// cloud default would contradict every privacy claim the app
// makes in its docs, App Store listing, and permission prompts.
engineMode: defaults.string(forKey: Keys.engineMode) ?? "local",
credentialSource: CredentialSource.fromStored(
defaults.string(forKey: Keys.credentialSource)
),
hasCompletedOnboarding: defaults.bool(forKey: Keys.hasCompletedOnboarding),
onboardingPage: {
let saved = defaults.integer(forKey: Keys.onboardingPage)
@@ -387,7 +403,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
// who later choose 30m / 10m again keep that choice.
let previousDefaults: Set<String> = [
FlowInactivityDuration.thirtyMinutes.rawValue,
FlowInactivityDuration.tenMinutes.rawValue,
FlowInactivityDuration.tenMinutes.rawValue
]
if previousDefaults.contains(config.flowInactivityDuration.rawValue) {
config.flowInactivityDuration = .default
@@ -415,6 +431,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
defaults.set(modeId, forKey: Keys.modeId)
defaults.set(localeId, forKey: Keys.localeId)
defaults.set(engineMode, forKey: Keys.engineMode)
defaults.set(credentialSource.rawValue, forKey: Keys.credentialSource)
defaults.set(hasCompletedOnboarding, forKey: Keys.hasCompletedOnboarding)
defaults.set(onboardingPage, forKey: Keys.onboardingPage)
defaults.set(hasAcknowledgedCloudSharing, forKey: Keys.hasAcknowledgedCloudSharing)
@@ -438,6 +455,123 @@ public struct AppGroupConfiguration: Sendable, Equatable {
Self.encodePolishStyleCatalog(polishStyleCatalog, to: defaults)
}
/// Persists only fields changed from the caller's last observed snapshot.
///
/// Main app and keyboard extension are separate processes. Rewriting every
/// key from a stale snapshot can undo a newer, unrelated setting written by
/// the other process. Field-level writes keep unrelated updates intact.
public func saveChanges(since baseline: Self, to defaults: UserDefaults) {
func set<Value: Equatable>(_ value: Value, previous: Value, key: String) {
guard value != previous else { return }
defaults.set(value, forKey: key)
}
set(providerId, previous: baseline.providerId, key: Keys.providerId)
set(baseURL, previous: baseline.baseURL, key: Keys.baseURL)
set(model, previous: baseline.model, key: Keys.model)
set(asrProviderId, previous: baseline.asrProviderId, key: Keys.asrProviderId)
set(asrBaseURL, previous: baseline.asrBaseURL, key: Keys.asrBaseURL)
set(asrModel, previous: baseline.asrModel, key: Keys.asrModel)
set(modeId, previous: baseline.modeId, key: Keys.modeId)
set(localeId, previous: baseline.localeId, key: Keys.localeId)
set(engineMode, previous: baseline.engineMode, key: Keys.engineMode)
set(
credentialSource.rawValue,
previous: baseline.credentialSource.rawValue,
key: Keys.credentialSource
)
set(
hasCompletedOnboarding,
previous: baseline.hasCompletedOnboarding,
key: Keys.hasCompletedOnboarding
)
set(onboardingPage, previous: baseline.onboardingPage, key: Keys.onboardingPage)
set(
hasAcknowledgedCloudSharing,
previous: baseline.hasAcknowledgedCloudSharing,
key: Keys.hasAcknowledgedCloudSharing
)
set(uiLanguage.rawValue, previous: baseline.uiLanguage.rawValue, key: Keys.uiLanguage)
set(
translationTargetLocaleId,
previous: baseline.translationTargetLocaleId,
key: Keys.translationTargetLocaleId
)
set(
handednessPreference.rawValue,
previous: baseline.handednessPreference.rawValue,
key: Keys.handednessPreference
)
set(
cursorDragNavigationEnabled,
previous: baseline.cursorDragNavigationEnabled,
key: Keys.cursorDragNavigationEnabled
)
set(
keyboardHapticIntensity.rawValue,
previous: baseline.keyboardHapticIntensity.rawValue,
key: Keys.keyboardHapticIntensity
)
set(
polishIntensity.rawValue,
previous: baseline.polishIntensity.rawValue,
key: Keys.polishIntensity
)
set(
aiResponseLength.rawValue,
previous: baseline.aiResponseLength.rawValue,
key: Keys.aiResponseLength
)
set(
llmThinkingEnabled,
previous: baseline.llmThinkingEnabled,
key: Keys.llmThinkingEnabled
)
set(
clipboardHistoryEnabled,
previous: baseline.clipboardHistoryEnabled,
key: Keys.clipboardHistoryEnabled
)
set(
clipboardCandidateBarEnabled,
previous: baseline.clipboardCandidateBarEnabled,
key: Keys.clipboardCandidateBarEnabled
)
set(
activePolishStyleId,
previous: baseline.activePolishStyleId,
key: Keys.activePolishStyleId
)
set(flowSkipAppSwitch, previous: baseline.flowSkipAppSwitch, key: Keys.flowSkipAppSwitch)
set(
flowInactivityDuration.rawValue,
previous: baseline.flowInactivityDuration.rawValue,
key: Keys.flowInactivityDuration
)
set(
localASRCustomLanguageModelEnabled,
previous: baseline.localASRCustomLanguageModelEnabled,
key: Keys.localASRCustomLanguageModelEnabled
)
set(
personalDictionaryICloudSyncEnabled,
previous: baseline.personalDictionaryICloudSyncEnabled,
key: Keys.personalDictionaryICloudSyncEnabled
)
set(
settingsICloudSyncEnabled,
previous: baseline.settingsICloudSyncEnabled,
key: Keys.settingsICloudSyncEnabled
)
if personalDictionary != baseline.personalDictionary {
Self.encodePersonalDictionary(personalDictionary, to: defaults)
}
if polishStyleCatalog != baseline.polishStyleCatalog {
Self.encodePolishStyleCatalog(polishStyleCatalog, to: defaults)
}
}
// MARK: - Private helpers
private static func decodePersonalDictionary(from defaults: UserDefaults) -> PersonalDictionary {
@@ -514,7 +648,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
"work": "builtin.formal",
"document": "builtin.structured",
"todo": "builtin.structured",
"social_lifestyle": "builtin.xhs",
"social_lifestyle": "builtin.xhs"
]
if let legacyID = defaults.string(forKey: Keys.legacyPolishScenarioId),
let mappedID = legacyMappings[legacyID] {
@@ -96,7 +96,7 @@ enum BuiltinPolishStyleLoader {
// fall back to main / class bundle the same way other Shared resources do.
var bundles: [Bundle] = [
Bundle(for: BundleToken.self),
Bundle.main,
Bundle.main
]
#if !os(macOS)
if let shared = Bundle(identifier: "com.osgkeyboard.ios.shared") {
@@ -71,7 +71,7 @@ public enum CloudASRModelCatalog {
"openrouter",
"mimo",
"volcengine",
"custom",
"custom"
]
/// Sync Fun-ASR Flash base64 upload, 5 min, supports context + vocabulary.
@@ -0,0 +1,16 @@
// CredentialSource.swift
// OSGKeyboard · Shared
//
// Credential ownership is independent from the ASR engine. This keeps local
// ASR + managed polish, direct BYOK cloud, and fully managed flows composable.
import Foundation
public enum CredentialSource: String, CaseIterable, Codable, Sendable {
case byok
case managed
public static func fromStored(_ value: String?) -> CredentialSource {
CredentialSource(rawValue: value ?? "") ?? .byok
}
}
@@ -20,8 +20,8 @@ public struct FlowCommand: Codable, Equatable, Sendable {
case submitAIQuestion
}
/// Wire version that includes submitAIQuestion + aiQuestionText.
public static let currentProtocolVersion = 5
/// Wire version that includes managed-gateway AI task intent.
public static let currentProtocolVersion = 6
public let protocolVersion: Int
public let sessionId: UUID
@@ -41,6 +41,8 @@ public struct FlowCommand: Codable, Equatable, Sendable {
public let aiConversationID: UUID?
/// Prefilled question used only by `.submitAIQuestion`.
public let aiQuestionText: String?
/// Fine-grained managed-gateway intent for AI question submissions.
public let aiTaskKind: ManagedGatewayTaskKind?
/// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
public let aiThinkingEnabled: Bool?
/// Absolute wall-clock deadlines survive extension reconstruction.
@@ -62,6 +64,7 @@ public struct FlowCommand: Codable, Equatable, Sendable {
sourceHistoryEntryRevision: Int64? = nil,
aiConversationID: UUID? = nil,
aiQuestionText: String? = nil,
aiTaskKind: ManagedGatewayTaskKind? = nil,
aiThinkingEnabled: Bool? = nil,
startDeadlineAt: TimeInterval? = nil,
processingDeadlineAt: TimeInterval? = nil
@@ -80,6 +83,7 @@ public struct FlowCommand: Codable, Equatable, Sendable {
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
self.aiConversationID = aiConversationID
self.aiQuestionText = aiQuestionText
self.aiTaskKind = aiTaskKind
self.aiThinkingEnabled = aiThinkingEnabled
self.startDeadlineAt = startDeadlineAt
self.processingDeadlineAt = processingDeadlineAt
@@ -37,7 +37,7 @@ public struct FlowFieldContext: Codable, Equatable, Sendable {
keyboardType ?? "",
returnKeyType ?? "",
precedingText.map { String($0.suffix(80)) } ?? "",
followingText.map { String($0.prefix(40)) } ?? "",
followingText.map { String($0.prefix(40)) } ?? ""
].joined(separator: "|")
}
}
@@ -13,6 +13,8 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
public let aiConversationID: UUID?
/// When set with `.aiQuestion`, host skips ASR and answers this text.
public let aiQuestionText: String?
/// Fine-grained managed-gateway intent. Regular questions keep the default.
public let aiTaskKind: ManagedGatewayTaskKind?
/// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
public let aiThinkingEnabled: Bool?
@@ -25,6 +27,7 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
sourceHistoryEntryRevision: Int64? = nil,
aiConversationID: UUID? = nil,
aiQuestionText: String? = nil,
aiTaskKind: ManagedGatewayTaskKind? = nil,
aiThinkingEnabled: Bool? = nil
) {
self.mode = mode
@@ -33,6 +36,7 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
self.aiConversationID = aiConversationID
self.aiQuestionText = aiQuestionText
self.aiTaskKind = aiTaskKind
self.aiThinkingEnabled = aiThinkingEnabled
}
@@ -53,12 +57,14 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
public static func aiQuestion(
conversationID: UUID,
prefilledQuestion: String? = nil,
taskKind: ManagedGatewayTaskKind = .aiQuestion,
thinkingEnabled: Bool? = nil
) -> FlowUtteranceRequest {
FlowUtteranceRequest(
mode: .aiQuestion,
aiConversationID: conversationID,
aiQuestionText: prefilledQuestion,
aiTaskKind: taskKind,
aiThinkingEnabled: thinkingEnabled
)
}
@@ -4,8 +4,8 @@
// Formats the user dictionary for cloud ASR bias (hotwords, Alibaba
// vocabulary entries, or Whisper-style prompt fragments).
import Foundation
import CryptoKit
import Foundation
public struct AlibabaHotwordEntry: Codable, Sendable, Equatable {
public let text: String
@@ -225,7 +225,7 @@ extension PersonalDictionary {
createdAt: Date(timeIntervalSince1970: 0),
updatedAt: Date(timeIntervalSince1970: 0),
usageCount: 0
),
)
]
/// User entries plus built-in system terms (deduped by term).
@@ -64,7 +64,7 @@ public struct PolishStylePack: Codable, Equatable, Identifiable, Sendable {
"outranks global R5",
"may add emojis",
"allow mood emoji",
"allowsAddedEmoji",
"allowsAddedEmoji"
]
return markers.contains { prompt.localizedCaseInsensitiveContains($0) }
}
+29 -5
View File
@@ -9,8 +9,8 @@
// inits after upgrade, a legacy plaintext value from UserDefaults is
// migrated to the Keychain and removed from UserDefaults.
import Foundation
import Combine
import Foundation
/// UI-owned ObservableObject; construct and mutate it on the main thread.
/// `@unchecked Sendable` does not make `@Published` thread-safe. Credential
@@ -125,6 +125,16 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
persistConfiguration(postConfigChanged: true)
}
}
/// Orthogonal to `engineMode`: direct provider credentials or an OSG
/// scope-limited grant. Local and BYOK behavior remain the default.
@Published public var credentialSource: CredentialSource {
didSet {
guard !isApplyingConfiguration,
credentialSource != configuration.credentialSource else { return }
configuration.credentialSource = credentialSource
persistConfiguration(postConfigChanged: true)
}
}
@Published public var hasCompletedOnboarding: Bool {
didSet {
guard !isApplyingConfiguration,
@@ -331,6 +341,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
}
public var isPolishConfigured: Bool {
if credentialSource == .managed { return true }
guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return false
}
@@ -339,6 +350,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
public var isASRConfigured: Bool {
guard !isLocalEngine else { return true }
if credentialSource == .managed { return true }
let key = asrApiKey.trimmingCharacters(in: .whitespacesAndNewlines)
let hasKey: Bool = {
if asrProviderId == "volcengine" {
@@ -360,6 +372,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
private let defaults: UserDefaults
private var configuration: AppGroupConfiguration
private var persistedConfigurationSnapshot: AppGroupConfiguration
/// Suppresses `@Published` observer persistence while a complete snapshot
/// or preset is applied, preventing reentrant writes of partial state.
private var isApplyingConfiguration = false
@@ -374,7 +387,9 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
)
}
self.defaults = resolvedDefaults
self.configuration = AppGroupConfiguration.load(fromAvailable: resolvedDefaults)
let loadedConfiguration = AppGroupConfiguration.load(fromAvailable: resolvedDefaults)
self.configuration = loadedConfiguration
self.persistedConfigurationSnapshot = loadedConfiguration
// Fresh app container (reinstall after delete): wipe stale Keychain
// onboarding so the welcome flow shows again. Reboot races still use
@@ -423,6 +438,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
modeId = configuration.modeId
localeId = configuration.localeId
engineMode = configuration.engineMode
credentialSource = configuration.credentialSource
hasCompletedOnboarding = configuration.hasCompletedOnboarding
onboardingPage = configuration.onboardingPage
hasAcknowledgedCloudSharing = configuration.hasAcknowledgedCloudSharing
@@ -469,6 +485,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
clipboardHistoryEnabled = false
clipboardCandidateBarEnabled = false
hasAcknowledgedCloudSharing = false
credentialSource = .byok
configuration.providerId = polishPreset.id
configuration.baseURL = polishPreset.defaultBaseURL
configuration.model = polishPreset.defaultModel
@@ -484,12 +501,14 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
configuration.clipboardHistoryEnabled = false
configuration.clipboardCandidateBarEnabled = false
configuration.hasAcknowledgedCloudSharing = false
configuration.credentialSource = .byok
isApplyingConfiguration = false
persistConfiguration()
}
private func persistConfiguration(postConfigChanged: Bool = false) {
configuration.save(to: defaults)
configuration.saveChanges(since: persistedConfigurationSnapshot, to: defaults)
persistedConfigurationSnapshot = configuration
if postConfigChanged {
AppGroupConfigDarwin.postConfigChanged()
}
@@ -498,7 +517,8 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
/// Re-read App Group defaults after a cloud pull updates the cache.
public func reloadFromPersistedStorage() {
var fresh = AppGroupConfiguration.load(fromAvailable: defaults)
let persisted = AppGroupConfiguration.load(fromAvailable: defaults)
var fresh = persisted
// Keep the reboot-durable onboarding marker authoritative across cloud
// pulls, matching the resilience applied at init.
let freshOnboarding = fresh.hasCompletedOnboarding
@@ -514,6 +534,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
}
isApplyingConfiguration = true
configuration = fresh
persistedConfigurationSnapshot = persisted
providerId = fresh.providerId
baseURL = fresh.baseURL
model = fresh.model
@@ -523,6 +544,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
modeId = fresh.modeId
localeId = fresh.localeId
engineMode = fresh.engineMode
credentialSource = fresh.credentialSource
hasCompletedOnboarding = fresh.hasCompletedOnboarding
onboardingPage = fresh.onboardingPage
hasAcknowledgedCloudSharing = fresh.hasAcknowledgedCloudSharing
@@ -576,6 +598,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
public func applyAsr(preset: LLMProvider) {
isApplyingConfiguration = true
engineMode = "cloud"
asrProviderId = preset.id
if !preset.defaultBaseURL.isEmpty {
asrBaseURL = preset.defaultBaseURL
@@ -584,10 +607,11 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
configuration.asrProviderId = asrProviderId
configuration.asrBaseURL = asrBaseURL
configuration.asrModel = asrModel
configuration.engineMode = engineMode
isSyncingASRProviderAPIKey = true
asrApiKey = configuration.asrApiKey
isSyncingASRProviderAPIKey = false
isApplyingConfiguration = false
persistConfiguration()
persistConfiguration(postConfigChanged: true)
}
}
@@ -254,7 +254,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
activePolishStyleId.updatedAt,
llmThinkingEnabled.updatedAt,
flowSkipAppSwitch.updatedAt,
flowInactivityDuration.updatedAt,
flowInactivityDuration.updatedAt
].max() ?? .distantPast
}
@@ -184,7 +184,7 @@ public struct SyncedUsageStatisticsV2: Codable, Equatable, Sendable {
dictationCharacterCount: legacy.dictationCharacterCount,
translationCharacterCount: legacy.translationCharacterCount,
aiCharacterCount: legacy.aiCharacterCount
),
)
])
}
}
@@ -82,16 +82,16 @@ public enum TranslationLanguageCatalog {
/// "turn off" action is one tap away from any enabled state.
public static let all: [TranslationLanguage] = [
TranslationLanguage(id: offLocaleId, promptLanguageName: "", nativeName: ""),
TranslationLanguage(id: "en", promptLanguageName: "English", nativeName: "English"),
TranslationLanguage(id: "en", promptLanguageName: "English", nativeName: "English"),
TranslationLanguage(id: "zh-Hans", promptLanguageName: "Simplified Chinese", nativeName: "简体中文"),
TranslationLanguage(id: "zh-Hant", promptLanguageName: "Traditional Chinese", nativeName: "繁體中文"),
TranslationLanguage(id: "ja", promptLanguageName: "Japanese", nativeName: "日本語"),
TranslationLanguage(id: "ko", promptLanguageName: "Korean", nativeName: "한국어"),
TranslationLanguage(id: "fr", promptLanguageName: "French", nativeName: "Français"),
TranslationLanguage(id: "de", promptLanguageName: "German", nativeName: "Deutsch"),
TranslationLanguage(id: "es", promptLanguageName: "Spanish", nativeName: "Español"),
TranslationLanguage(id: "ru", promptLanguageName: "Russian", nativeName: "Русский"),
TranslationLanguage(id: "pt", promptLanguageName: "Portuguese", nativeName: "Português"),
TranslationLanguage(id: "ja", promptLanguageName: "Japanese", nativeName: "日本語"),
TranslationLanguage(id: "ko", promptLanguageName: "Korean", nativeName: "한국어"),
TranslationLanguage(id: "fr", promptLanguageName: "French", nativeName: "Français"),
TranslationLanguage(id: "de", promptLanguageName: "German", nativeName: "Deutsch"),
TranslationLanguage(id: "es", promptLanguageName: "Spanish", nativeName: "Español"),
TranslationLanguage(id: "ru", promptLanguageName: "Russian", nativeName: "Русский"),
TranslationLanguage(id: "pt", promptLanguageName: "Portuguese", nativeName: "Português")
]
/// True when the given id is the "off" sentinel. Used by the picker
@@ -113,4 +113,4 @@ public enum TranslationLanguageCatalog {
}
return all.first { $0.id == offLocaleId } ?? all[0]
}
}
}
@@ -4,8 +4,8 @@
// App Group-backed Chinese input settings shared by the host app and
// keyboard extension. Fuzzy pairs are opt-in to avoid noisy candidates.
import Foundation
import Combine
import Foundation
public enum TypingInputSchema: String, CaseIterable, Identifiable, Codable, Sendable {
case fullPinyin = "osg_pinyin"
@@ -55,7 +55,7 @@ public struct VolcengineASRFields: Sendable, Equatable {
public var encodedAPIKey: String {
var object: [String: String] = [
"auth_mode": authMode.rawValue,
"resource_id": Self.fixedResourceID,
"resource_id": Self.fixedResourceID
]
// Persist both credential sets so toggling auth mode is non-destructive.
if !appID.isEmpty { object["app_id"] = appID }
@@ -27,7 +27,7 @@ public enum AIAddressExtraction: Sendable {
"", "没有", "没有地址", "没有地点", "无地址", "无地点",
"没有可导航的地点", "没有可导航的地址",
"no address", "no addresses", "no location", "no locations",
"no place", "no places", "no destination",
"no place", "no places", "no destination"
]
/// Lines to send to the host. Empty do not run the companion Shortcut.
@@ -49,7 +49,7 @@ public enum AIAgentShortcutRun {
var items = [
URLQueryItem(name: "name", value: name),
URLQueryItem(name: "input", value: "text"),
URLQueryItem(name: "text", value: text),
URLQueryItem(name: "text", value: text)
]
if let xSuccess {
items.append(URLQueryItem(name: "x-success", value: xSuccess))
@@ -4,8 +4,8 @@
// Observable facade over the persisted skill layout. The Skills tab mutates
// this; the keyboard reads the same App Group snapshot on each config poll.
import Foundation
import Combine
import Foundation
@MainActor
public final class AIAgentSkillLayoutStore: ObservableObject {
@@ -65,7 +65,7 @@ public enum AIClipboardPrompt: Sendable {
}
private static let keywords = [
"剪贴板", "剪切板", "剪贴版", "粘贴板", "clipboard",
"剪贴板", "剪切板", "剪贴版", "粘贴板", "clipboard"
]
private static func trimmed(_ text: String) -> String {
@@ -43,6 +43,11 @@ public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
/// Navigate and Ride hand off to the host (Maps or Didi). No Shortcut.
public var requiresShortcut: Bool { kind == .export && shortcutName != nil }
public var isUserCreated: Bool { id.hasPrefix("user.") }
/// The server applies the final model policy; this only preserves whether
/// the user invoked a built-in transform or a custom skill.
public var managedGatewayTaskKind: ManagedGatewayTaskKind {
isUserCreated ? .customSkill : .clipboardTransform
}
public init(
id: String,
@@ -165,7 +170,7 @@ public enum AIClipboardSkillCatalog: Sendable {
descriptionKey: "skills.navigate.description",
kind: .export,
isDefault: false
),
)
]
/// Legacy alias: the three default transform skills used to be the whole list.
@@ -19,7 +19,7 @@ public enum AIEventExtraction: Sendable {
"", "没有", "没有日程", "没有事件", "无日程",
"没有日期", "没有时间", "没有日期或时间", "没有日期和时间",
"no events", "no event", "no calendar events",
"no date", "no time", "no date or time", "no date and time",
"no date", "no time", "no date or time", "no date and time"
]
/// Lines to send to the companion Shortcut. Empty do not run it.
@@ -37,7 +37,7 @@ public struct AIHintKeywordCompressor: Sendable {
"text": $0.displayText,
"title": $0.metadata?.title ?? "",
"category": $0.category,
"source": $0.source,
"source": $0.source
]
}
let json = try JSONSerialization.data(withJSONObject: payload)
@@ -88,12 +88,12 @@ public enum AIHintKeywordExtractor: Sendable {
private static let zhPrefixes = [
"全网热点:", "全网热点:", "临近节日:", "临近节日:",
"历史上的今天:", "历史上的今天:", "今日一句:", "今日一句:",
"查百科:", "查百科:", "聊聊", "看看",
"查百科:", "查百科:", "聊聊", "看看"
]
private static let enPrefixes = [
"Trending: ", "Upcoming: ", "On this day: ",
"Chat about ", "Chat ", "Weather in ",
"Chat about ", "Chat ", "Weather in "
]
// MARK: - Chunks
@@ -88,7 +88,7 @@ public enum AIHintLocalCatalog: Sendable {
priority: 36,
source: "local",
locale: "zh"
),
)
]
private static let enCards: [AIHintCard] = [
@@ -168,6 +168,6 @@ public enum AIHintLocalCatalog: Sendable {
priority: 36,
source: "local",
locale: "en"
),
)
]
}
@@ -60,7 +60,7 @@ public enum AIMapNavigation: Sendable {
URLQueryItem(name: "sname", value: originName),
URLQueryItem(name: "dname", value: route.destination),
URLQueryItem(name: "dev", value: "0"),
URLQueryItem(name: "t", value: "0"),
URLQueryItem(name: "t", value: "0")
]
if useLegacyScheme {
return makeURL(scheme: "iosamap", host: "path", path: nil, items: items)
@@ -78,7 +78,7 @@ public enum AIMapNavigation: Sendable {
var items = [
URLQueryItem(name: "destination", value: "name:\(route.destination)"),
URLQueryItem(name: "mode", value: "driving"),
URLQueryItem(name: "src", value: baiduSource),
URLQueryItem(name: "src", value: baiduSource)
]
if let origin = route.origin {
items.insert(
@@ -92,7 +92,7 @@ public enum AIMapNavigation: Sendable {
private static func appleURL(_ route: AIMapRoute) -> URL {
var items = [
URLQueryItem(name: "daddr", value: route.destination),
URLQueryItem(name: "dirflg", value: "d"),
URLQueryItem(name: "dirflg", value: "d")
]
if let origin = route.origin {
items.insert(URLQueryItem(name: "saddr", value: origin), at: 0)
@@ -20,7 +20,7 @@ public enum AINoteExport: Sendable {
private static let emptyTokens: Set<String> = [
"none", "no", "n/a", "na", "nil", "null",
"", "没有", "没有标题", "无标题",
"no title", "no note", "no notes",
"no title", "no note", "no notes"
]
/// One string for Shortcuts: `title||OSG_NOTE||body`. Empty do not run it.
@@ -139,7 +139,7 @@ public enum AINoteExport: Sendable {
("", ""),
("", ""),
("'", "'"),
("", ""),
("", "")
]
var text = line
for (open, close) in pairs where text.count >= 2 {
@@ -58,7 +58,7 @@ public enum AIQuestionPromptComposer {
.system(systemPrompt(
targetLocaleID: targetLocaleID,
responseLength: responseLength
)),
))
]
for turn in turns.suffix(AIQuestionLimits.retainedConversationRounds) {
messages.append(.user(turn.question))
@@ -128,8 +128,20 @@ public struct AIQuestionService: Sendable {
public static func configured(
store: any ConfigurationStore,
conversations: AIConversationStore,
taskKind: ManagedGatewayTaskKind = .aiQuestion,
thinkingEnabled: Bool = true
) throws -> AIQuestionService {
if store.credentialSource == .managed {
return AIQuestionService(
client: ManagedLLMClient(
capability: .assistant,
taskKind: taskKind,
grants: GatewayGrantCoordinator()
),
conversations: conversations,
responseLength: store.aiResponseLength
)
}
// Same provider + baseURL + model resolution as dictation polish so the
// Settings LLM card is the single source of truth for both modes.
let providerID = PolishingService.resolvedProviderId(
@@ -15,7 +15,7 @@ public enum AITodoExtraction: Sendable {
"none", "no", "n/a", "na", "nil", "null",
"", "没有", "没有待办", "没有待办事项", "无待办", "无待办事项",
"no tasks", "no task", "no todos", "no to-dos", "no to-do",
"no actionable items", "no action items",
"no actionable items", "no action items"
]
/// Titles to send to the companion Shortcut. Empty do not run it.
@@ -45,7 +45,7 @@ public struct AnthropicMessagesClient: LLMClient {
try await complete(
messages: [
.system(systemPrompt),
.user(text),
.user(text)
],
timeout: timeout,
options: options
@@ -168,13 +168,13 @@ public struct AnthropicMessagesClient: LLMClient {
// Anthropic requires max_tokens > thinking.budget_tokens.
"max_tokens": thinkingEnabled ? answerTokens + thinkingBudget : answerTokens,
"system": systemPrompt,
"messages": conversation,
"messages": conversation
]
if thinkingEnabled {
// Extended thinking; sampling knobs are ignored while thinking runs.
body["thinking"] = [
"type": "enabled",
"budget_tokens": thinkingBudget,
"budget_tokens": thinkingBudget
]
} else {
if let temperature = options.temperature {
@@ -190,8 +190,8 @@ public struct AnthropicMessagesClient: LLMClient {
[
"type": "web_search_20250305",
"name": "web_search",
"max_uses": 3,
],
"max_uses": 3
]
]
}
if stream {
@@ -93,7 +93,7 @@ public struct AppContextDetector: Sendable {
"import ", "package ", "namespace ",
"def ", "var ", "let ", "const ",
"if (", "if (", "} else", "} catch",
"=> {", "-> {",
"=> {", "-> {"
]
let hasIndentation = tail.contains("\n ") || tail.contains("\t")
let hasCodeKeyword = codeKeywords.contains(where: { tail.contains($0) })
+11 -4
View File
@@ -56,9 +56,10 @@ public struct AppGroupStore: @unchecked Sendable {
}
private func mutateConfiguration(_ transform: (inout AppGroupConfiguration) -> Void) {
var config = AppGroupConfiguration.load(fromAvailable: defaults)
let baseline = AppGroupConfiguration.load(fromAvailable: defaults)
var config = baseline
transform(&config)
config.save(to: defaults)
config.saveChanges(since: baseline, to: defaults)
}
// MARK: - Reads
@@ -74,6 +75,7 @@ public struct AppGroupStore: @unchecked Sendable {
public var modeId: String { configuration.modeId }
public var localeId: String { configuration.localeId }
public var engineMode: String { configuration.engineMode }
public var credentialSource: CredentialSource { configuration.credentialSource }
public var uiLanguage: AppUILanguage { configuration.uiLanguage }
public var translationEnabled: Bool { configuration.translationEnabled }
public var translationTargetLocaleId: String { configuration.translationTargetLocaleId }
@@ -121,6 +123,11 @@ public struct AppGroupStore: @unchecked Sendable {
AppGroupConfigDarwin.postConfigChanged()
}
public func setCredentialSource(_ source: CredentialSource) {
mutateConfiguration { $0.credentialSource = source }
AppGroupConfigDarwin.postConfigChanged()
}
public func setUILanguage(_ language: AppUILanguage) {
mutateConfiguration { $0.uiLanguage = language }
}
@@ -387,7 +394,7 @@ public struct AppGroupStore: @unchecked Sendable {
// MARK: - Client
public func makeClient() -> LLMClient {
configuration.makeClient()
public func makeClient(taskKind: ManagedGatewayTaskKind?) -> LLMClient {
configuration.makeClient(taskKind: taskKind)
}
}
@@ -194,7 +194,7 @@ public enum ClipboardHistoryPolicy: Sendable {
("xoxa-", 24, true),
("xoxr-", 24, true),
("AKIA", 20, true),
("ASIA", 20, true),
("ASIA", 20, true)
]
return credentialCandidates(in: text).contains { candidate in
guard candidate.allSatisfy(isCredentialCharacter) else { return false }
@@ -3,8 +3,8 @@
//
// App Groupbacked clipboard history (local only; not iCloud-synced).
import Foundation
import Combine
import Foundation
@MainActor
public final class ClipboardHistoryStore: ObservableObject {
@@ -84,7 +84,7 @@ public enum DemoDataSeeder {
("cloud", "Summarize yesterday's dictation stats for the weekly report."),
("local", "词库里加上 Cursor、DeepSeek、Qwen3-ASR,方便识别专有名词。"),
("local", "跨设备同步先关掉,演示数据用本地占位,避免被 iCloud 覆盖。"),
("local", "把首页近七天柱状图补齐,看起来更有真实使用痕迹。"),
("local", "把首页近七天柱状图补齐,看起来更有真实使用痕迹。")
]
var entries: [SpeechHistoryEntry] = []
@@ -119,7 +119,7 @@ public enum DemoDataSeeder {
("SpeechAnalyzer", ["语音分析器"], .technical, 7),
("Live Activity", ["灵动岛"], .custom, 5),
("StoreKit", ["内购"], .technical, 4),
("Rocky", ["rocky"], .properNoun, 3),
("Rocky", ["rocky"], .properNoun, 3)
]
var dictionary = PersonalDictionary()
@@ -181,9 +181,14 @@ public final class SettingsCloudSync {
to store: AppGroupStore,
postNotification: Bool
) {
var config = store.configurationSnapshot()
let baseline = store.configurationSnapshot()
var config = baseline
settings.applying(to: &config)
store.saveConfiguration(config, settingsCloudUpdatedAt: settings.latestUpdatedAt)
store.saveConfiguration(
config,
since: baseline,
settingsCloudUpdatedAt: settings.latestUpdatedAt
)
saveLocalPayload(settings, to: store.defaults)
if postNotification {
AppGroupConfigDarwin.postConfigChanged()
@@ -220,9 +225,12 @@ private extension AppGroupStore {
AppGroupConfiguration.load(fromAvailable: defaults)
}
func saveConfiguration(_ configuration: AppGroupConfiguration, settingsCloudUpdatedAt: Date) {
let config = configuration
config.save(to: defaults)
func saveConfiguration(
_ configuration: AppGroupConfiguration,
since baseline: AppGroupConfiguration,
settingsCloudUpdatedAt: Date
) {
configuration.saveChanges(since: baseline, to: defaults)
defaults.set(
settingsCloudUpdatedAt.timeIntervalSince1970,
forKey: AppGroupConfiguration.Keys.settingsCloudUpdatedAt
+19 -19
View File
@@ -7,8 +7,8 @@
// (`KeyboardViewController`) re-exports the same type as a typealias so
// existing call sites (`KeyboardViewController.State`) keep compiling.
import Foundation
import Combine
import Foundation
import SwiftUI
import UIKit
@@ -310,13 +310,13 @@ public final class KeyboardState: ObservableObject {
}
// Action hooks injected by the view controller at install time.
public var beginRecording: () -> Void = {}
public var endRecording: () -> Void = {}
public var tapMic: () -> Void = {}
public var beginRecording: () -> Void = {}
public var endRecording: () -> Void = {}
public var tapMic: () -> Void = {}
/// Starts/cancels a bounded host-audio prime from the user's mic touch.
public var setMicTouchActive: (Bool) -> Void = { _ in }
public var setMicTouchActive: (Bool) -> Void = { _ in }
/// Discards the complete normal-dictation round, including late ASR/LLM output.
public var cancelVoiceInput: () -> Void = {}
public var cancelVoiceInput: () -> Void = {}
public var beginEditLastInput: () -> Void = {}
public var stopEditListening: () -> Void = {}
public var confirmEditResult: () -> Void = {}
@@ -334,7 +334,7 @@ public final class KeyboardState: ObservableObject {
public var submitAIClipboardSkill: (AIClipboardSkill) -> Void = { _ in }
/// Writes extract-todos titles and opens the host to run the Shortcut.
public var runClipboardExportSkill: (String, [String]) -> Void = { _, _ in }
public var openSettings: () -> Void = {}
public var openSettings: () -> Void = {}
/// Opens the host app straight to input-resource deployment. Used by the
/// typing surface when Rime resources have not been deployed yet.
public var openInputMethodSetup: () -> Void = {}
@@ -353,25 +353,25 @@ public final class KeyboardState: ObservableObject {
/// ownership cycle; UIKit's standard all-touch-events action provides both
/// tap-to-advance and long-press input-mode selection.
public weak var inputModeController: UIInputViewController?
public var startFlowSession: () -> Void = {}
public var setMode: (InputMode) -> Void = { _ in }
public var setLocale: (String) -> Void = { _ in }
public var setEngineMode: (String) -> Void = { _ in }
public var startFlowSession: () -> Void = {}
public var setMode: (InputMode) -> Void = { _ in }
public var setLocale: (String) -> Void = { _ in }
public var setEngineMode: (String) -> Void = { _ in }
/// Only the locale picker remains; `enabled`
/// is derived from the locale id, so there's no separate toggle to
/// persist. Wired in `KeyboardViewController.installStateActions`.
public var setTranslationTargetLocaleId: (String) -> Void = { _ in }
public var insertNewline: () -> Void = {}
public var insertSpace: () -> Void = {}
public var deleteBackward: () -> Void = {}
public var insertNewline: () -> Void = {}
public var insertSpace: () -> Void = {}
public var deleteBackward: () -> Void = {}
/// Undo the last voice insertion when `undoAvailable` is true.
public var undoLastInsertion: () -> Void = {}
public var undoLastInsertion: () -> Void = {}
/// Redo the last undone voice insertion when `redoAvailable` is true.
public var redoLastInsertion: () -> Void = {}
public var redoLastInsertion: () -> Void = {}
/// Copy the current text selection to the pasteboard.
public var copySelection: () -> Void = {}
public var copySelection: () -> Void = {}
/// Cut the current text selection (copy + delete).
public var cutSelection: () -> Void = {}
public var cutSelection: () -> Void = {}
/// Switch voice typing. No-ops when voice pipeline is active.
public var setSurface: (Surface) -> Void = { _ in }
@@ -455,4 +455,4 @@ extension KeyboardState.Phase.ErrorKind {
return .noSpeechDetected
}
}
}
}
+6 -6
View File
@@ -116,7 +116,7 @@ public enum Keychain: @unchecked Sendable {
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account(for: providerId),
kSecAttrSynchronizable as String: synchronizable ? kCFBooleanTrue! : kCFBooleanFalse!,
kSecAttrSynchronizable as String: synchronizable ? kCFBooleanTrue! : kCFBooleanFalse!
]
#if os(macOS)
query[kSecUseDataProtectionKeychain as String] = true
@@ -252,7 +252,7 @@ public enum Keychain: @unchecked Sendable {
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: asrAccount(for: providerId),
kSecAttrSynchronizable as String: synchronizable ? kCFBooleanTrue! : kCFBooleanFalse!,
kSecAttrSynchronizable as String: synchronizable ? kCFBooleanTrue! : kCFBooleanFalse!
]
#if os(macOS)
query[kSecUseDataProtectionKeychain as String] = true
@@ -393,7 +393,7 @@ public enum Keychain: @unchecked Sendable {
kSecAttrService as String: service,
kSecAttrAccount as String: legacyAccount,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecMatchLimit as String: kSecMatchLimitOne
]
#if os(macOS)
query[kSecUseDataProtectionKeychain as String] = true
@@ -513,7 +513,7 @@ public enum Keychain: @unchecked Sendable {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: legacyAccount,
kSecAttrAccount as String: legacyAccount
]
#if os(macOS)
query[kSecUseDataProtectionKeychain as String] = true
@@ -933,7 +933,7 @@ public enum Keychain: @unchecked Sendable {
kSecAttrService as String: onboardingService,
kSecAttrAccount as String: onboardingAccount,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecMatchLimit as String: kSecMatchLimitOne
]
#if os(macOS)
query[kSecUseDataProtectionKeychain as String] = true
@@ -962,7 +962,7 @@ public enum Keychain: @unchecked Sendable {
var baseQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: onboardingService,
kSecAttrAccount as String: onboardingAccount,
kSecAttrAccount as String: onboardingAccount
]
#if os(macOS)
baseQuery[kSecUseDataProtectionKeychain as String] = true
+2 -2
View File
@@ -48,7 +48,7 @@ enum LLMHTTPDiagnostics {
"x-request-id",
"request-id",
"x-correlation-id",
"cf-ray",
"cf-ray"
]
.compactMap { response.value(forHTTPHeaderField: $0) }
.compactMap(safeToken)
@@ -240,7 +240,7 @@ public struct OpenAICompatibleClient: LLMClient {
try await complete(
messages: [
.system(systemPrompt),
.user(text),
.user(text)
],
timeout: timeout,
options: options
@@ -18,7 +18,7 @@ public enum LocalASRBiasAdapter {
"com.sublimetext.4",
"com.apple.Terminal",
"com.googlecode.iterm2",
"dev.warp.Warp-Stable",
"dev.warp.Warp-Stable"
]
public static func adapt(
@@ -52,7 +52,7 @@ public enum PolishOutputValidator {
#"https?://[^\s<>"']+"#,
#"\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b"#,
#"\b[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+\b"#,
#"\b[A-Za-z]+[a-z0-9][A-Z][A-Za-z0-9]*\b"#,
#"\b[A-Za-z]+[a-z0-9][A-Z][A-Za-z0-9]*\b"#
]
var result = Set<String>()
for pattern in patterns {
@@ -48,6 +48,15 @@ public actor PolishingService {
let qualityDegraded: Bool
}
private struct PolishRequest {
let raw: String
let mode: PolishMode
let systemPrompt: String?
let providerIdOverride: String?
let taskKind: ManagedGatewayTaskKind?
let context: PolishContext?
}
public enum PolishError: Error, Equatable {
case noTranscript
case timeout
@@ -100,14 +109,18 @@ public actor PolishingService {
mode: PolishMode = .polish,
systemPrompt: String? = nil,
providerIdOverride: String? = nil,
taskKind: ManagedGatewayTaskKind? = nil,
context: PolishContext? = nil
) async throws -> String {
try await performPolish(
raw,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
context: context
PolishRequest(
raw: raw,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
taskKind: taskKind,
context: context
)
).text
}
@@ -118,28 +131,31 @@ public actor PolishingService {
mode: PolishMode = .polish,
systemPrompt: String? = nil,
providerIdOverride: String? = nil,
taskKind: ManagedGatewayTaskKind? = nil,
context: PolishContext? = nil
) async throws -> PolishOutcome {
try await performPolish(
raw,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
context: context
PolishRequest(
raw: raw,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
taskKind: taskKind,
context: context
)
)
}
private func performPolish(
_ raw: String,
mode: PolishMode,
systemPrompt: String?,
providerIdOverride: String?,
context: PolishContext?
) async throws -> PolishOutcome {
private func performPolish(_ request: PolishRequest) async throws -> PolishOutcome {
let raw = request.raw
let mode = request.mode
let systemPrompt = request.systemPrompt
let providerIdOverride = request.providerIdOverride
let taskKind = request.taskKind
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
let resolvedContext = resolveContext(override: context)
let resolvedContext = resolveContext(override: request.context)
let activeStyleID = store.activePolishStyleId
// Two-tier short-circuit: ultra-short always; 510 CJK only for
@@ -158,7 +174,7 @@ public actor PolishingService {
return PolishOutcome(text: TranscriptPostProcessor.localClean(trimmed))
}
if injectedClient == nil {
if injectedClient == nil, store.credentialSource == .byok {
let providerId = Self.resolvedProviderId(store: store, providerIdOverride: providerIdOverride)
let hasPolishKey = Self.hasPolishAPIKey(store: store, providerId: providerId)
guard hasPolishKey else {
@@ -174,6 +190,7 @@ public actor PolishingService {
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
taskKind: taskKind,
context: resolvedContext
)
@@ -197,11 +214,21 @@ public actor PolishingService {
return override
}
static func managedGatewayTaskKind(for mode: PolishMode) -> ManagedGatewayTaskKind {
switch mode {
case .polish:
return .dictationPolish
case .translate:
return .translation
}
}
private func polishRemote(
_ trimmed: String,
mode: PolishMode,
systemPrompt: String? = nil,
providerIdOverride: String? = nil,
taskKind: ManagedGatewayTaskKind? = nil,
context: PolishContext
) async throws -> RemotePolishResult {
let effectiveProviderId = Self.resolvedProviderId(
@@ -211,6 +238,10 @@ public actor PolishingService {
let client: LLMClient
if let injectedClient {
client = injectedClient
} else if store.credentialSource == .managed {
client = store.makeClient(
taskKind: taskKind ?? Self.managedGatewayTaskKind(for: mode)
)
} else {
let preset = LLMProvider.provider(id: effectiveProviderId)
let (baseURL, model) = Self.resolveLLMEndpoint(
@@ -418,7 +449,7 @@ public actor PolishingService {
}
internal static let chineseNativeProviderIds: Set<String> = [
"zhipu", "moonshot", "qwen", "deepseek", "ark", "minimax", "siliconflow", "mimo",
"zhipu", "moonshot", "qwen", "deepseek", "ark", "minimax", "siliconflow", "mimo"
]
internal static func shouldUseChineseGuidance(inputText: String, providerId: String) -> Bool {
@@ -152,7 +152,7 @@ public struct ResponsesAPILLMClient: LLMClient {
"tool_choice": "auto",
"max_output_tokens": options.maxTokens ?? LLMRequest.outputTokenLimit(
for: messages.map(\.content).joined(separator: "\n")
),
)
]
if let system, !system.isEmpty {
body["instructions"] = system
@@ -21,15 +21,15 @@ public enum SearchBodyAugmentation: Sendable, Equatable {
body["tools"] = [
[
"type": "web_search",
"web_search": ["enable": true],
],
"web_search": ["enable": true]
]
]
case .moonshotBuiltinWebSearch:
body["tools"] = [
[
"type": "builtin_function",
"function": ["name": "$web_search"],
],
"function": ["name": "$web_search"]
]
]
}
}
@@ -88,7 +88,7 @@ public enum TranscriptPostProcessor: Sendable {
#"^(好的?|嗯)?(收到|谢谢)(你|啦|了|啊)?$"#,
#"^(没事)?(不用|别)(了|啦)?(谢谢)?$"#,
#"^(晚安|早安|早上好|拜拜|再见)(啦|了|啊)?$"#,
#"^(晚点再说|待会联系|先这样吧|马上到了)$"#,
#"^(晚点再说|待会联系|先这样吧|马上到了)$"#
]
private static func hasCommunicativeSignal(_ text: String) -> Bool {
@@ -97,7 +97,7 @@ public enum TranscriptPostProcessor: Sendable {
#"吗|么|怎么|什么|哪|谁|为何|为什么|为啥"#,
#"能不能|可不可以|要不要|行不行"#,
#"回他|回她"#,
#"约|见面|吃饭|电影"#,
#"约|见面|吃饭|电影"#
]
return patterns.contains { text.range(of: $0, options: .regularExpression) != nil }
}
@@ -112,13 +112,13 @@ public enum TranscriptPostProcessor: Sendable {
"面膜", "防晒", "口红", "粉底", "洗发", "咖啡", "火锅", "酒店", "餐厅",
"方案", "接口", "测试", "Key", "老板", "电影", "地铁", "快递", "会议",
"周报", "加班", "机票", "医院", "课程", "健身", "外卖", "微信", "项目",
"发布", "文档", "密码", "充电器", "门卡",
"发布", "文档", "密码", "充电器", "门卡"
]
return entities.contains { text.contains($0) }
}
private static let leadingFillers = [
"怎么说呢", "就是说", "然后那个", "嗯那个", "那个", "", "",
"怎么说呢", "就是说", "然后那个", "嗯那个", "那个", "", ""
]
private static func stripLeadingFillers(_ text: String) -> String {
@@ -249,7 +249,7 @@ public enum TranscriptPostProcessor: Sendable {
#"首先|其次|再次|最后|另外|再者|一方面|另一方面"#,
#"\b(first|second|third|fourth|fifth|finally|next|another)\b"#,
#"\b(step\s*(one|two|three|four|five|\d+))\b"#,
#"point\s*(one|two|three|four|five|\d+)"#,
#"point\s*(one|two|three|four|five|\d+)"#
]
for pattern in patterns {
if text.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil {
@@ -356,7 +356,7 @@ public enum TranscriptPostProcessor: Sendable {
// Collapse duplicate Chinese / Western punctuation.
let dupPairs = [
("。。", ""), (",,", ""), ("??", ""), ("!!", ""),
("..", "."), (",,", ","), ("??", "?"), ("!!", "!"),
("..", "."), (",,", ","), ("??", "?"), ("!!", "!")
]
for (dup, single) in dupPairs {
while result.contains(dup) {
@@ -418,7 +418,7 @@ public enum TranscriptPostProcessor: Sendable {
public static func stripExplanatoryPrefix(from text: String) -> String {
let prefixes = [
"以下是", "处理后", "处理后的文本", "输出如下", "结果如下",
"Here is", "Here's", "Output:", "Result:", "Processed text:",
"Here is", "Here's", "Output:", "Result:", "Processed text:"
]
var result = text
for prefix in prefixes {
@@ -449,7 +449,7 @@ public enum TranscriptPostProcessor: Sendable {
private static func endsWithSentenceTerminator(_ text: String) -> Bool {
guard let last = text.unicodeScalars.last else { return false }
let terminators: Set<Unicode.Scalar> = [
"", "", "", "", "!", "?", ".", ";", "", "", ":",
"", "", "", "", "!", "?", ".", ";", "", "", ":"
]
return terminators.contains(last)
}
@@ -35,6 +35,16 @@ public enum TranscriptionPolishFallback: Sendable {
if error is LLMError {
return degradedWarning()
}
if let managedError = error as? ManagedGatewayError {
switch managedError {
case .insufficientCredits:
return SharedL10n.string("flow.warning.managedInsufficientCredits")
case .missingGrant, .scopeNotGranted, .invalidGrant:
return SharedL10n.string("flow.warning.managedGrantRejected")
case .timeout, .server:
return degradedWarning()
}
}
return nil
}
@@ -73,8 +73,7 @@ public enum PinyinNextKeyResolver {
private static func collectNext(prefix: String, into result: inout Set<Character>) {
var canExtend = false
for syllable in PinyinSyllableTable.syllables
where syllable.hasPrefix(prefix) && syllable.count > prefix.count
{
where syllable.hasPrefix(prefix) && syllable.count > prefix.count {
let index = syllable.index(syllable.startIndex, offsetBy: prefix.count)
result.insert(syllable[index])
canExtend = true
@@ -5,8 +5,8 @@
// an already-built session, keeping expensive maintenance work out of
// the extension's constrained lifecycle.
import Foundation
import Darwin
import Foundation
public enum RimeResourceError: LocalizedError {
case appGroupUnavailable
@@ -4,8 +4,8 @@
// Owns the typing-surface engine + layout provider. Injected into the
// keyboard extension; torn down when leaving typing mode.
import Foundation
import Combine
import Foundation
@MainActor
public final class TypingSessionController: ObservableObject {
@@ -5,8 +5,8 @@
// The host process never starts this monitor, so shared typing code can emit
// extension-only milestones without duplicating host telemetry.
import Foundation
import Darwin
import Foundation
public enum KeyboardExtensionMemoryBudget {
/// Start preserving evidence before the extension reaches its safe ceiling.
+1 -1
View File
@@ -5,8 +5,8 @@
// Console without needing an os.Logger subsystem filter (keyboard extension
// OSLog lines are easy to miss when the host process is selected).
import Foundation
import Darwin
import Foundation
public enum OSGDiag {
public struct MemorySnapshot: Sendable, Equatable {
@@ -5,8 +5,8 @@
// Short-form presets may emit a new time range after ~30 s; treating the
// latest partial as the full transcript drops earlier segments.
import Foundation
import CoreMedia
import Foundation
/// Combines volatile partials and finalized segments from
/// `DictationTranscriber.results` into a single growing transcript.
@@ -13,8 +13,10 @@ public enum UtteranceBatchFallbackPolicy {
public static func shouldRunBatchFallback(
stitchedFinal: String,
partialSnapshot: String,
recognitionFailed: Bool = false,
minimumCharacterAdvantage: Int = defaultCharacterAdvantage
) -> Bool {
if recognitionFailed { return true }
let final = stitchedFinal.trimmingCharacters(in: .whitespacesAndNewlines)
let partial = partialSnapshot.trimmingCharacters(in: .whitespacesAndNewlines)
+19
View File
@@ -10,6 +10,8 @@
"flow.warning.localPolishUnavailable" = "Add an API key in Settings to enable polish. Inserted raw ASR text.";
"flow.warning.polishDegraded" = "Weak network — inserted raw ASR text without polish.";
"flow.warning.polishDegradedQuality" = "Polish output failed validation; inserted a conservative version.";
"flow.warning.managedInsufficientCredits" = "Managed credits are insufficient. Inserted raw ASR text; open the Account tab to add credits.";
"flow.warning.managedGrantRejected" = "Managed service authorization expired. Inserted raw ASR text; open the main app to reconnect your account.";
/* LLM providers */
"provider.openai" = "OpenAI";
@@ -64,6 +66,23 @@
"error.cloudASR.providerUnsupported" = "This provider does not support cloud speech recognition yet.";
"error.cloudASR.streamingNotImplemented" = "Streaming ASR failed for this provider. Check your API key and network, or try again.";
/* Managed account gateway */
"managed.error.grantUnavailable" = "Managed service is not ready. Open the main app and reconnect your account.";
"managed.error.scopeNotGranted" = "Managed service access is missing for %@. Open the main app and reconnect your account.";
"managed.error.grantRejected" = "Managed service authorization expired. Open the main app and reconnect your account.";
"managed.error.insufficientCredits" = "Not enough credits. Open the Account tab in the main app to add credits.";
"managed.error.timeout" = "Managed service timed out. Check your connection and try again.";
"managed.error.server" = "Managed service failed (%1$@, HTTP %2$lld). Try again later.";
"managed.asr.error.invalidConfiguration" = "Managed speech settings are invalid. Open the main app and select the service again.";
"managed.asr.error.concurrencyLimit" = "Another managed speech session is active. Stop it and try again.";
"managed.asr.error.sessionCreation" = "Managed speech could not start. Check your connection and try again.";
"managed.asr.error.transport" = "Managed speech connection failed. Check your network and try again.";
"managed.asr.error.connectTimeout" = "Managed speech took too long to connect. Check your network and try again.";
"managed.asr.error.idleTimeout" = "Managed speech stopped because no audio was received. Tap the microphone and try again.";
"managed.asr.error.streaming" = "Managed speech streaming was interrupted. Check your network and try again.";
"managed.asr.error.invalidResult" = "Managed speech returned an invalid result. Please try again.";
"managed.asr.error.batch" = "Managed speech retry failed. Check your network and try again.";
/* Provider tools */
"providerTools.error.invalidURL" = "Invalid model endpoint.";
"providerTools.error.missingAPIKey" = "API Key is missing.";
@@ -10,6 +10,8 @@
"flow.warning.localPolishUnavailable" = "请先在设置中填写 API Key 才能润色,本次已插入原始识别结果。";
"flow.warning.polishDegraded" = "弱网识别,本次未润色,已插入原始识别结果。";
"flow.warning.polishDegradedQuality" = "本次润色结果未通过校验,已插入保守处理版本。";
"flow.warning.managedInsufficientCredits" = "积分不足,本次已插入原始识别结果;请打开主 App 的「账户」页充值。";
"flow.warning.managedGrantRejected" = "托管服务授权已失效,本次已插入原始识别结果;请打开主 App 重新连接账号。";
/* LLM providers */
"provider.openai" = "OpenAI";
@@ -64,6 +66,23 @@
"error.cloudASR.providerUnsupported" = "该服务商暂不支持云端语音识别。";
"error.cloudASR.streamingNotImplemented" = "该服务商流式 ASR 失败。请检查 API Key 与网络后重试。";
/* 托管账号网关 */
"managed.error.grantUnavailable" = "托管服务尚未就绪,请打开主 App 重新连接账号。";
"managed.error.scopeNotGranted" = "托管服务缺少 %@ 权限,请打开主 App 重新连接账号。";
"managed.error.grantRejected" = "托管服务授权已失效,请打开主 App 重新连接账号。";
"managed.error.insufficientCredits" = "积分不足,请打开主 App 的「账户」页充值。";
"managed.error.timeout" = "托管服务请求超时,请检查网络后重试。";
"managed.error.server" = "托管服务失败(%1$@HTTP %2$lld),请稍后重试。";
"managed.asr.error.invalidConfiguration" = "托管语音设置无效,请打开主 App 重新选择服务。";
"managed.asr.error.concurrencyLimit" = "另一个托管语音会话仍在进行,请结束后重试。";
"managed.asr.error.sessionCreation" = "无法启动托管语音,请检查网络后重试。";
"managed.asr.error.transport" = "托管语音连接失败,请检查网络后重试。";
"managed.asr.error.connectTimeout" = "托管语音连接超时,请检查网络后重试。";
"managed.asr.error.idleTimeout" = "长时间未收到音频,托管语音已停止,请重新点击麦克风。";
"managed.asr.error.streaming" = "托管语音流已中断,请检查网络后重试。";
"managed.asr.error.invalidResult" = "托管语音返回结果无效,请重试。";
"managed.asr.error.batch" = "托管语音重试失败,请检查网络后重试。";
/* 服务商工具 */
"providerTools.error.invalidURL" = "模型接口地址无效。";
"providerTools.error.missingAPIKey" = "未填写 API Key。";