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:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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) })
|
||||
|
||||
@@ -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 Group–backed 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; 5–10 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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。";
|
||||
|
||||
Reference in New Issue
Block a user