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:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user