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:
Rocky
2026-08-06 15:11:12 +08:00
parent 2ed16e1fbf
commit a8d58d8f0c
47 changed files with 1467 additions and 258 deletions
@@ -140,7 +140,13 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public var isCloudASRKeyMissing: Bool {
guard engineMode == "cloud" else { return false }
return asrApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
let key = asrApiKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return true }
// Volcengine may store auth_mode JSON before credentials are filled.
if asrProviderId == "volcengine" {
return !VolcengineASRFields.parse(apiKey: key).hasUsableCredentials
}
return false
}
public var isPolishKeyMissing: Bool {
+1 -1
View File
@@ -191,7 +191,7 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
defaultBaseURL: "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async",
defaultModel: "volc.seedasr.sauc.duration",
apiKeyURL: URL(string: "https://console.volcengine.com/speech"),
blurb: "流式大模型 ASR · API Key 填 appId:accessToken[:resourceId]",
blurb: "流式大模型 ASR · 旧版 AppID+Token / 新版 API Key",
isUserSelectable: false
),
.init(
@@ -15,6 +15,9 @@ public struct PolishStylePack: Codable, Equatable, Identifiable, Sendable {
public let id: String
public var name: String
public var prompt: String
/// When true, polish may keep model-added emoji and the prompt overrides R5.
/// Defaults off so existing / builtin styles stay emoji-strict.
public var allowsAddedEmoji: Bool
public let kind: Kind
public let createdAt: Date
public var updatedAt: Date
@@ -23,6 +26,7 @@ public struct PolishStylePack: Codable, Equatable, Identifiable, Sendable {
id: String = "user.\(UUID().uuidString.lowercased())",
name: String,
prompt: String,
allowsAddedEmoji: Bool = false,
kind: Kind = .user,
createdAt: Date = Date(),
updatedAt: Date? = nil
@@ -30,6 +34,7 @@ public struct PolishStylePack: Codable, Equatable, Identifiable, Sendable {
self.id = id
self.name = name
self.prompt = prompt
self.allowsAddedEmoji = allowsAddedEmoji
self.kind = kind
self.createdAt = createdAt
self.updatedAt = updatedAt ?? createdAt
@@ -39,6 +44,46 @@ public struct PolishStylePack: Codable, Equatable, Identifiable, Sendable {
guard kind == .builtin else { return name }
return SharedL10n.string("polishStyle.\(id.dropFirst("builtin.".count))", language: language)
}
/// Effective emoji policy for polish: explicit toggle, or a custom prompt that
/// clearly opts in (so paste-only custom styles still keep model-added emoji).
public var effectiveAllowsAddedEmoji: Bool {
if allowsAddedEmoji { return true }
guard kind == .user else { return false }
return Self.promptDeclaresAddedEmojiOptIn(prompt)
}
/// Heuristic for custom prompts that declare add mood emoji themselves.
public static func promptDeclaresAddedEmojiOptIn(_ prompt: String) -> Bool {
let markers = [
"允许新增 emoji",
"允许新增emoji",
"按情绪点缀",
"按原文情绪",
"Emoji 覆盖",
"outranks global R5",
"may add emojis",
"allow mood emoji",
"allowsAddedEmoji",
]
return markers.contains { prompt.localizedCaseInsensitiveContains($0) }
}
private enum CodingKeys: String, CodingKey {
case id, name, prompt, allowsAddedEmoji, kind, createdAt, updatedAt
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
prompt = try container.decode(String.self, forKey: .prompt)
// Older synced packs omit the key stay emoji-strict.
allowsAddedEmoji = try container.decodeIfPresent(Bool.self, forKey: .allowsAddedEmoji) ?? false
kind = try container.decode(Kind.self, forKey: .kind)
createdAt = try container.decode(Date.self, forKey: .createdAt)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
}
}
public enum PolishStyleLimits {
@@ -92,6 +137,7 @@ public struct PolishStyleCatalog: Codable, Equatable, Sendable {
var updated = pack
updated.name = name
updated.prompt = prompt
updated.allowsAddedEmoji = pack.allowsAddedEmoji
updated.updatedAt = date
entries[index] = updated
} else {
@@ -101,6 +147,7 @@ public struct PolishStyleCatalog: Codable, Equatable, Sendable {
var created = pack
created.name = name
created.prompt = prompt
created.allowsAddedEmoji = pack.allowsAddedEmoji
created.updatedAt = date
entries.append(created)
}
@@ -313,7 +313,14 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
public var isASRConfigured: Bool {
guard !isLocalEngine else { return true }
return !asrApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
let key = asrApiKey.trimmingCharacters(in: .whitespacesAndNewlines)
let hasKey: Bool = {
if asrProviderId == "volcengine" {
return VolcengineASRFields.parse(apiKey: key).hasUsableCredentials
}
return !key.isEmpty
}()
return hasKey
&& (!asrBaseURL.isEmpty || CloudASRModelCatalog.strategy(for: asrProviderId) != .prompt)
}
@@ -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 {