feat(polish): allow mood emoji on custom styles and ship Flow/ASR fixes
Custom polish styles can opt in to emotion-matched emoji (default off), with prompt-level opt-in detection so paste-only styles keep model-added emoji. Also include Volcengine API-Key ASR auth, voice-processing capture, PiP flash fix, and related keyboard Shift/haptics reliability work.
This commit is contained in:
@@ -2,65 +2,155 @@
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Parse / encode Volcengine SAUC credentials stored in the ASR API key field.
|
||||
// Supports legacy AppID+AccessToken (old console) and single API Key (new console).
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Volcengine speech console auth style for SAUC streaming ASR.
|
||||
public enum VolcengineASRAuthMode: String, Sendable, Equatable {
|
||||
/// Old console: `X-Api-App-Key` + `X-Api-Access-Key`.
|
||||
case appToken = "app_token"
|
||||
/// New console: single `X-Api-Key`.
|
||||
case apiKey = "api_key"
|
||||
}
|
||||
|
||||
public struct VolcengineASRFields: Sendable, Equatable {
|
||||
public var authMode: VolcengineASRAuthMode
|
||||
public var appID: String
|
||||
public var accessToken: String
|
||||
public var resourceID: String
|
||||
/// New-console API Key (`X-Api-Key`). Kept alongside app-token fields so the
|
||||
/// settings toggle can switch modes without wiping the other credential set.
|
||||
public var apiKeyCredential: String
|
||||
|
||||
/// Locked product: Doubao streaming ASR 2.0 · duration billing.
|
||||
public static let fixedResourceID = CloudASRModelCatalog.volcengineDefaultResourceID
|
||||
|
||||
public init(
|
||||
authMode: VolcengineASRAuthMode = .appToken,
|
||||
appID: String = "",
|
||||
accessToken: String = "",
|
||||
resourceID: String = CloudASRModelCatalog.defaultModel(for: "volcengine")
|
||||
apiKeyCredential: String = ""
|
||||
) {
|
||||
self.authMode = authMode
|
||||
self.appID = appID
|
||||
self.accessToken = accessToken
|
||||
self.resourceID = resourceID
|
||||
self.apiKeyCredential = apiKeyCredential
|
||||
}
|
||||
|
||||
/// Always `volc.seedasr.sauc.duration` — not user-editable.
|
||||
public var resourceID: String { Self.fixedResourceID }
|
||||
|
||||
public var usesAPIKeyAuth: Bool { authMode == .apiKey }
|
||||
|
||||
public var hasUsableCredentials: Bool {
|
||||
switch authMode {
|
||||
case .appToken:
|
||||
return !appID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& !accessToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
case .apiKey:
|
||||
return !apiKeyCredential.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
public var encodedAPIKey: String {
|
||||
let object = [
|
||||
"app_id": appID,
|
||||
"access_token": accessToken,
|
||||
"resource_id": resourceID,
|
||||
var object: [String: String] = [
|
||||
"auth_mode": authMode.rawValue,
|
||||
"resource_id": Self.fixedResourceID,
|
||||
]
|
||||
// Persist both credential sets so toggling auth mode is non-destructive.
|
||||
if !appID.isEmpty { object["app_id"] = appID }
|
||||
if !accessToken.isEmpty { object["access_token"] = accessToken }
|
||||
if !apiKeyCredential.isEmpty { object["api_key"] = apiKeyCredential }
|
||||
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: object),
|
||||
let string = String(data: data, encoding: .utf8) else {
|
||||
return [appID, accessToken, resourceID].joined(separator: ":")
|
||||
switch authMode {
|
||||
case .appToken:
|
||||
return [appID, accessToken, Self.fixedResourceID].joined(separator: ":")
|
||||
case .apiKey:
|
||||
return apiKeyCredential
|
||||
}
|
||||
}
|
||||
return string
|
||||
}
|
||||
|
||||
public static func parse(apiKey: String, resourceFallback: String) -> VolcengineASRFields {
|
||||
/// Apply SAUC WebSocket handshake headers for the active auth mode.
|
||||
public func applyWebSocketAuthHeaders(to request: inout URLRequest, connectID: String) {
|
||||
request.setValue(Self.fixedResourceID, forHTTPHeaderField: "X-Api-Resource-Id")
|
||||
request.setValue(connectID, forHTTPHeaderField: "X-Api-Connect-Id")
|
||||
switch authMode {
|
||||
case .apiKey:
|
||||
request.setValue(
|
||||
apiKeyCredential.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
forHTTPHeaderField: "X-Api-Key"
|
||||
)
|
||||
case .appToken:
|
||||
request.setValue(
|
||||
appID.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
forHTTPHeaderField: "X-Api-App-Key"
|
||||
)
|
||||
request.setValue(
|
||||
accessToken.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
forHTTPHeaderField: "X-Api-Access-Key"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// - Parameter resourceFallback: Ignored; resource is always `fixedResourceID`.
|
||||
/// Kept so call sites stay source-compatible.
|
||||
public static func parse(apiKey: String, resourceFallback: String = "") -> VolcengineASRFields {
|
||||
_ = resourceFallback
|
||||
let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var fields = VolcengineASRFields(
|
||||
appID: "",
|
||||
accessToken: "",
|
||||
resourceID: resourceFallback.isEmpty
|
||||
? CloudASRModelCatalog.defaultModel(for: "volcengine")
|
||||
: resourceFallback
|
||||
)
|
||||
var fields = VolcengineASRFields()
|
||||
|
||||
guard !trimmed.isEmpty else { return fields }
|
||||
|
||||
if let data = trimmed.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
fields.appID = string(json, keys: ["app_id", "appId", "appid"]) ?? ""
|
||||
fields.accessToken = string(json, keys: ["access_token", "accessToken", "token"]) ?? ""
|
||||
fields.resourceID = string(json, keys: ["resource_id", "resourceId", "resource"]) ?? fields.resourceID
|
||||
fields.apiKeyCredential = string(json, keys: ["api_key", "apiKey"]) ?? ""
|
||||
fields.authMode = resolveAuthMode(
|
||||
raw: string(json, keys: ["auth_mode", "authMode"]),
|
||||
hasAPIKey: !fields.apiKeyCredential.isEmpty,
|
||||
hasAppToken: !fields.appID.isEmpty && !fields.accessToken.isEmpty
|
||||
)
|
||||
return fields
|
||||
}
|
||||
|
||||
// Legacy colon form is always old-console app-token auth.
|
||||
let parts = trimmed
|
||||
.components(separatedBy: CharacterSet(charactersIn: ":\n,"))
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
if parts.indices.contains(0) { fields.appID = parts[0] }
|
||||
if parts.indices.contains(1) { fields.accessToken = parts[1] }
|
||||
if parts.indices.contains(2) { fields.resourceID = parts[2] }
|
||||
fields.authMode = .appToken
|
||||
return fields
|
||||
}
|
||||
|
||||
private static func resolveAuthMode(
|
||||
raw: String?,
|
||||
hasAPIKey: Bool,
|
||||
hasAppToken: Bool
|
||||
) -> VolcengineASRAuthMode {
|
||||
if let raw {
|
||||
let normalized = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if normalized == VolcengineASRAuthMode.apiKey.rawValue || normalized == "apikey" {
|
||||
return .apiKey
|
||||
}
|
||||
if normalized == VolcengineASRAuthMode.appToken.rawValue
|
||||
|| normalized == "apptoken"
|
||||
|| normalized == "app_id_token" {
|
||||
return .appToken
|
||||
}
|
||||
}
|
||||
// Legacy JSON without auth_mode: prefer app-token when present.
|
||||
if hasAppToken { return .appToken }
|
||||
if hasAPIKey { return .apiKey }
|
||||
return .appToken
|
||||
}
|
||||
|
||||
private static func string(_ json: [String: Any], keys: [String]) -> String? {
|
||||
for key in keys {
|
||||
if let value = json[key] as? String {
|
||||
|
||||
Reference in New Issue
Block a user