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 {
|
||||
|
||||
@@ -14,8 +14,39 @@ public enum FlowKeyboardHostWarming {
|
||||
reason == .recording || reason == .processing || reason == .awaitingDelivery
|
||||
}
|
||||
|
||||
/// Keep the mic green after the session has already proven ready.
|
||||
///
|
||||
/// Inter-utterance PiP flaps (mic release, ack lag, brief `reason=.starting`)
|
||||
/// used to flash yellow「正在启动画中画」even though Picture in Picture was
|
||||
/// already running. Hold ready through those windows; real cold starts still
|
||||
/// go through `isHostWarming` while `sessionProvenReady` is false.
|
||||
public static func shouldHoldReady(
|
||||
hostReady: Bool,
|
||||
hostBusy: Bool,
|
||||
sessionActive: Bool,
|
||||
sessionProvenReady: Bool,
|
||||
isPendingFlowStart: Bool,
|
||||
snapshotReason: FlowReadySnapshot.Reason?
|
||||
) -> Bool {
|
||||
guard !hostReady,
|
||||
sessionProvenReady,
|
||||
sessionActive,
|
||||
!isPendingFlowStart else {
|
||||
return false
|
||||
}
|
||||
// After insert the host may still publish awaitingDelivery until it
|
||||
// consumes the ack — that is not a PiP restart.
|
||||
if hostBusy {
|
||||
return snapshotReason == .awaitingDelivery
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Session lives but ready contract is not fresh — keep mic orange (wait)
|
||||
/// instead of launching another cold start.
|
||||
///
|
||||
/// `withinReadyGrace` is intentionally unused for warming: a recent ready
|
||||
/// must hold green via `shouldHoldReady`, not flash preparingSession.
|
||||
public static func isHostWarming(
|
||||
hostReady: Bool,
|
||||
hostBusy: Bool,
|
||||
@@ -25,13 +56,13 @@ public enum FlowKeyboardHostWarming {
|
||||
withinReadyGrace: Bool,
|
||||
snapshotReason: FlowReadySnapshot.Reason?
|
||||
) -> Bool {
|
||||
!hostReady
|
||||
_ = withinReadyGrace
|
||||
return !hostReady
|
||||
&& !hostBusy
|
||||
&& sessionActive
|
||||
&& (
|
||||
hostReachable
|
||||
|| isPendingFlowStart
|
||||
|| withinReadyGrace
|
||||
|| snapshotReason == .starting
|
||||
)
|
||||
}
|
||||
|
||||
@@ -671,6 +671,8 @@ public enum FlowSessionBridge {
|
||||
store.removeObject(forKey: FlowSessionKeys.audioLevels)
|
||||
store.removeObject(forKey: FlowSessionKeys.lastActivityAt)
|
||||
clearHostReady(defaults: store, notify: false)
|
||||
// Previous generation may have died mid Rime/CLM/ASR with hostHeavy=1.
|
||||
clearHostHeavy(defaults: store)
|
||||
flush(store)
|
||||
}
|
||||
|
||||
@@ -701,13 +703,47 @@ public enum FlowSessionBridge {
|
||||
/// avoid stacking typing-engine RSS on top.
|
||||
public static func setHostHeavy(_ heavy: Bool, defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.set(heavy, forKey: FlowSessionKeys.hostHeavy)
|
||||
if heavy {
|
||||
store.set(true, forKey: FlowSessionKeys.hostHeavy)
|
||||
store.set(Date().timeIntervalSince1970, forKey: FlowSessionKeys.hostHeavyAt)
|
||||
} else {
|
||||
clearHostHeavy(defaults: store)
|
||||
}
|
||||
flush(store)
|
||||
OSGDiag.log("hostHeavy=\(heavy ? 1 : 0) \(OSGDiag.memoryTag())", category: "flow")
|
||||
}
|
||||
|
||||
/// True only while the host recently marked itself busy. A sticky `true`
|
||||
/// left by a dead host (no `setHostHeavy(false)`) expires after
|
||||
/// `hostHeavyMaxAge` so typing 中文/EN is not silently blocked forever.
|
||||
public static func isHostHeavy(defaults: UserDefaults? = nil) -> Bool {
|
||||
resolvedDefaults(defaults).bool(forKey: FlowSessionKeys.hostHeavy)
|
||||
let store = resolvedDefaults(defaults)
|
||||
guard store.bool(forKey: FlowSessionKeys.hostHeavy) else { return false }
|
||||
let markedAt = store.double(forKey: FlowSessionKeys.hostHeavyAt)
|
||||
// Legacy writes had the bool but no timestamp — treat as stale so a
|
||||
// pre-fix sticky flag cannot brick typing after upgrade.
|
||||
guard markedAt > 0 else {
|
||||
clearHostHeavy(defaults: store)
|
||||
flush(store)
|
||||
OSGDiag.log("hostHeavy stale missingAt — cleared \(OSGDiag.memoryTag())", category: "flow")
|
||||
return false
|
||||
}
|
||||
let age = Date().timeIntervalSince1970 - markedAt
|
||||
guard age >= 0, age <= FlowSessionKeys.hostHeavyMaxAge else {
|
||||
clearHostHeavy(defaults: store)
|
||||
flush(store)
|
||||
OSGDiag.log(
|
||||
"hostHeavy stale age=\(Int(age))s — cleared \(OSGDiag.memoryTag())",
|
||||
category: "flow"
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private static func clearHostHeavy(defaults: UserDefaults) {
|
||||
defaults.set(false, forKey: FlowSessionKeys.hostHeavy)
|
||||
defaults.removeObject(forKey: FlowSessionKeys.hostHeavyAt)
|
||||
}
|
||||
|
||||
/// True when the host has published a fresh ready contract (stricter than heartbeat alone).
|
||||
@@ -948,6 +984,7 @@ public enum FlowSessionBridge {
|
||||
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId)
|
||||
store.removeObject(forKey: FlowSessionKeys.lastActivityAt)
|
||||
clearHostReady(defaults: store, notify: false)
|
||||
clearHostHeavy(defaults: store)
|
||||
flush(store)
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,15 @@ public enum FlowSessionKeys {
|
||||
/// Host is mid heavy work (Rime/CLM/ASR). Extension should stay on voice
|
||||
/// and skip typing engine prepare until this clears.
|
||||
public static let hostHeavy = "flow.hostHeavy.v1"
|
||||
/// Wall-clock timestamp paired with `hostHeavy` (seconds since 1970).
|
||||
/// Lets the keyboard ignore a sticky flag left behind when the host died
|
||||
/// mid-warmup without ever clearing App Group state.
|
||||
public static let hostHeavyAt = "flow.hostHeavyAt.v1"
|
||||
|
||||
/// `hostHeavy` older than this is treated as stale (host likely jetsammed
|
||||
/// or force-quit before `setHostHeavy(false)`). Rime/CLM/ASR bursts are
|
||||
/// expected well under this window.
|
||||
public static let hostHeavyMaxAge: TimeInterval = 120
|
||||
|
||||
/// Heartbeat older than this → host is not actively reachable for recording.
|
||||
public static let heartbeatStaleInterval: TimeInterval = 3
|
||||
|
||||
@@ -211,6 +211,10 @@ public enum PolishPromptComposer {
|
||||
dictionaryBlock,
|
||||
useChineseGuidance: useChineseGuidance
|
||||
)
|
||||
let emojiOverride = emojiOverrideSection(
|
||||
allowsAddedEmoji: style.effectiveAllowsAddedEmoji,
|
||||
useChineseGuidance: useChineseGuidance
|
||||
)
|
||||
if usesHeavyFunPipeline {
|
||||
let formatting = useChineseGuidance
|
||||
? chineseFunFormattingPrompt
|
||||
@@ -226,6 +230,7 @@ public enum PolishPromptComposer {
|
||||
\(personality)
|
||||
|
||||
\(outputInstruction)
|
||||
\(emojiOverride)
|
||||
"""
|
||||
}
|
||||
|
||||
@@ -262,6 +267,7 @@ public enum PolishPromptComposer {
|
||||
fieldHints: context.fieldHints,
|
||||
useChineseGuidance: true
|
||||
))用户消息即为待处理的转写文本。只输出处理后的文本。
|
||||
\(emojiOverride)
|
||||
"""
|
||||
}
|
||||
|
||||
@@ -284,6 +290,7 @@ public enum PolishPromptComposer {
|
||||
fieldHints: context.fieldHints,
|
||||
useChineseGuidance: false
|
||||
))The user message is the transcript to process. Output the processed text only.
|
||||
\(emojiOverride)
|
||||
"""
|
||||
}
|
||||
|
||||
@@ -346,6 +353,8 @@ public enum PolishPromptComposer {
|
||||
useChineseGuidance: Bool
|
||||
) -> String {
|
||||
let body = PolishStylePackCatalog.runtimePersonality(for: style)
|
||||
// Emoji override is appended at the end of compose(), not here —
|
||||
// it must outrank core R5 and later context premises.
|
||||
if style.kind == .user {
|
||||
return useChineseGuidance
|
||||
? """
|
||||
@@ -370,6 +379,30 @@ public enum PolishPromptComposer {
|
||||
"""
|
||||
}
|
||||
|
||||
/// When a style opts in, override global R5 so the model may add emoji.
|
||||
/// Placed last in the composed prompt so it wins over core R5.
|
||||
private static func emojiOverrideSection(
|
||||
allowsAddedEmoji: Bool,
|
||||
useChineseGuidance: Bool
|
||||
) -> String {
|
||||
guard allowsAddedEmoji else { return "" }
|
||||
return useChineseGuidance
|
||||
? """
|
||||
|
||||
# Emoji 覆盖(本风格开启 · 最终优先级)
|
||||
本风格允许新增 emoji,优先级高于全局 R5「不新增 emoji」以及上文任何「不要加 emoji」表述。
|
||||
仅按原文已表达的情绪点缀 0–2 个贴合语气的 emoji;中性安排、正式通知与极短确认词不加。
|
||||
原文已有 emoji 时只整理文字,不替换、不堆叠。禁止无关装饰与 emoji 墙。
|
||||
"""
|
||||
: """
|
||||
|
||||
# Emoji override (enabled for this style · final priority)
|
||||
This style may add emojis and outranks global R5 (“add no emojis”) and any earlier “do not add emojis” guidance.
|
||||
Add 0–2 tone-matching emojis only when the draft already expresses emotion; skip neutral schedules, formal notices, and ultra-short acks.
|
||||
If the draft already has emojis, keep them and do not replace or stack. No decorative spam or emoji walls.
|
||||
"""
|
||||
}
|
||||
|
||||
/// Neutralize envelope-breaking tags inside user-controlled transcript text.
|
||||
internal static func sanitizeEnvelopeContent(_ text: String) -> String {
|
||||
let maxCharacters = 16_000
|
||||
|
||||
@@ -285,7 +285,15 @@ public actor PolishingService {
|
||||
|
||||
// One prompt, one model request. Deterministic validation may reject a
|
||||
// result locally, but it never starts a second polish request.
|
||||
let firstCandidate = TranscriptPostProcessor.process(original: trimmed, llmOutput: first)
|
||||
let activeStyle = PolishStylePackCatalog.resolve(
|
||||
id: store.activePolishStyleId,
|
||||
userCatalog: store.polishStyleCatalog
|
||||
)
|
||||
let firstCandidate = TranscriptPostProcessor.process(
|
||||
original: trimmed,
|
||||
llmOutput: first,
|
||||
allowsAddedEmoji: activeStyle.effectiveAllowsAddedEmoji
|
||||
)
|
||||
let firstViolations = PolishOutputValidator.validate(
|
||||
input: trimmed,
|
||||
output: firstCandidate,
|
||||
|
||||
@@ -169,9 +169,17 @@ public enum TranscriptPostProcessor: Sendable {
|
||||
// MARK: - Post-LLM pipeline
|
||||
|
||||
/// Apply deterministic cleanup and quality gate to LLM output.
|
||||
public static func process(original: String, llmOutput: String) -> String {
|
||||
public static func process(
|
||||
original: String,
|
||||
llmOutput: String,
|
||||
allowsAddedEmoji: Bool = false
|
||||
) -> String {
|
||||
let trimmedOriginal = original.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let decision = qualityGate(original: trimmedOriginal, candidate: llmOutput)
|
||||
let decision = qualityGate(
|
||||
original: trimmedOriginal,
|
||||
candidate: llmOutput,
|
||||
allowsAddedEmoji: allowsAddedEmoji
|
||||
)
|
||||
switch decision {
|
||||
case .accept(let text):
|
||||
return text
|
||||
@@ -191,7 +199,11 @@ public enum TranscriptPostProcessor: Sendable {
|
||||
/// back when the model returned genuinely unusable output (empty, or
|
||||
/// pure explanation), and even then we prefer a cleaned candidate
|
||||
/// over the raw transcript.
|
||||
public static func qualityGate(original: String, candidate: String) -> GateDecision {
|
||||
public static func qualityGate(
|
||||
original: String,
|
||||
candidate: String,
|
||||
allowsAddedEmoji: Bool = false
|
||||
) -> GateDecision {
|
||||
var text = candidate.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if text.isEmpty {
|
||||
@@ -201,7 +213,9 @@ public enum TranscriptPostProcessor: Sendable {
|
||||
text = stripExplanatoryPrefix(from: text)
|
||||
text = stripPauseMarkers(from: text)
|
||||
text = unwrapSurroundingQuotes(text)
|
||||
text = stripAddedEmojis(original: original, output: text)
|
||||
if !allowsAddedEmoji {
|
||||
text = stripAddedEmojis(original: original, output: text)
|
||||
}
|
||||
text = repairMidSentenceLineBreaks(text)
|
||||
text = normalizeWhitespaceAndPunctuation(text)
|
||||
text = normalizeNumberedLists(text)
|
||||
|
||||
@@ -42,20 +42,22 @@ public enum TypingAutocapitalization: Sendable {
|
||||
private static func needsSentenceCapitalization(_ preceding: String?) -> Bool {
|
||||
guard let preceding, !preceding.isEmpty else { return true }
|
||||
|
||||
// Walk backward past trailing whitespace / newlines; capitalize when
|
||||
// the field is empty or the previous visible character ends a sentence.
|
||||
// Walk backward from the caret. Trailing spaces are ignored; a newline
|
||||
// itself starts a new line (system .sentences behavior, e.g. Notes
|
||||
// after Return). Otherwise capitalize only after a sentence terminator.
|
||||
var index = preceding.endIndex
|
||||
var sawContent = false
|
||||
while index > preceding.startIndex {
|
||||
index = preceding.index(before: index)
|
||||
let character = preceding[index]
|
||||
if character.isWhitespace || character.isNewline {
|
||||
if character.isNewline {
|
||||
return true
|
||||
}
|
||||
if character.isWhitespace {
|
||||
continue
|
||||
}
|
||||
sawContent = true
|
||||
return isSentenceTerminator(character)
|
||||
}
|
||||
return !sawContent
|
||||
return true
|
||||
}
|
||||
|
||||
private static func isSentenceTerminator(_ character: Character) -> Bool {
|
||||
|
||||
@@ -68,6 +68,10 @@ public final class TypingSessionController: ObservableObject {
|
||||
private var shiftPrimedByUser = false
|
||||
/// True if any key was typed while the current Shift hold was active.
|
||||
private var typedWhileShiftHeld = false
|
||||
/// Local caret-prefix mirror so autocap survives stale `documentContextBeforeInput`
|
||||
/// (common in Notes). Capped; reseeds from the proxy when it looks fresh.
|
||||
private var precedingShadow = ""
|
||||
private static let precedingShadowLimit = 400
|
||||
|
||||
public init(
|
||||
engine: (@MainActor () -> RimeEngineBridging)? = nil,
|
||||
@@ -287,6 +291,14 @@ public final class TypingSessionController: ObservableObject {
|
||||
return handleEnglishCharacter(ch)
|
||||
}
|
||||
|
||||
// Chinese + Shift: insert Latin directly (iOS-style mix-in), leave Rime
|
||||
// composition untouched. Rime's alphabet is lowercase-only, so uppercase
|
||||
// keycodes would otherwise be rejected with no output.
|
||||
if isShiftEnabled, ch.isLetter {
|
||||
clearOneShotShiftIfNeeded()
|
||||
return .insert(String(ch))
|
||||
}
|
||||
|
||||
// Chinese letters → compose
|
||||
let committed = engine.processCharacter(ch) ?? ""
|
||||
composition = engine.composition
|
||||
@@ -490,17 +502,94 @@ public final class TypingSessionController: ObservableObject {
|
||||
|
||||
/// Arms Shift for sentence / word starts using the host field traits.
|
||||
/// Manual one-shot, hold, and Caps Lock always win over autocapitalization.
|
||||
public func syncAutocapitalization() {
|
||||
/// - Parameters:
|
||||
/// - insert: Text just written through the document proxy (may not be
|
||||
/// reflected in `documentContextBeforeInput` yet).
|
||||
/// - deleteCount: Characters just deleted before `insert` (replace path).
|
||||
public func syncAutocapitalization(
|
||||
accountingForInsert insert: String = "",
|
||||
deleteCount: Int = 0
|
||||
) {
|
||||
guard language == .english, page == .letters else { return }
|
||||
guard !capsLock, !shiftHeld, !shiftPrimedByUser else { return }
|
||||
let mode = autocapitalizationModeProvider?() ?? .sentences
|
||||
let preceding = precedingTextProvider?()
|
||||
let preceding = resolvedPrecedingText(
|
||||
accountingForInsert: insert,
|
||||
deleteCount: deleteCount
|
||||
)
|
||||
shiftActive = TypingAutocapitalization.shouldCapitalize(
|
||||
precedingText: preceding,
|
||||
mode: mode
|
||||
)
|
||||
}
|
||||
|
||||
/// Prefer a fresh proxy; when the host lags (Notes), merge our just-applied edit.
|
||||
private func resolvedPrecedingText(
|
||||
accountingForInsert insert: String,
|
||||
deleteCount: Int
|
||||
) -> String? {
|
||||
// Host-driven refresh (appear / textDidChange): reseed from proxy.
|
||||
if deleteCount == 0, insert.isEmpty {
|
||||
if let proxy = precedingTextProvider?() {
|
||||
return storePrecedingShadow(proxy)
|
||||
}
|
||||
return precedingShadow.isEmpty ? nil : precedingShadow
|
||||
}
|
||||
|
||||
let proxy = precedingTextProvider?()
|
||||
|
||||
if deleteCount > 0, !insert.isEmpty {
|
||||
if let proxy {
|
||||
if proxy.hasSuffix(insert) {
|
||||
return storePrecedingShadow(proxy)
|
||||
}
|
||||
if proxy.count >= deleteCount {
|
||||
return storePrecedingShadow(String(proxy.dropLast(deleteCount)) + insert)
|
||||
}
|
||||
}
|
||||
trimPrecedingShadow(by: deleteCount)
|
||||
return storePrecedingShadow(precedingShadow + insert)
|
||||
}
|
||||
|
||||
if deleteCount > 0 {
|
||||
if let proxy, proxy.count + deleteCount == precedingShadow.count
|
||||
|| (precedingShadow.count >= deleteCount
|
||||
&& String(precedingShadow.dropLast(deleteCount)) == proxy) {
|
||||
return storePrecedingShadow(proxy)
|
||||
}
|
||||
if precedingShadow.count >= deleteCount {
|
||||
return storePrecedingShadow(String(precedingShadow.dropLast(deleteCount)))
|
||||
}
|
||||
if let proxy { return storePrecedingShadow(proxy) }
|
||||
precedingShadow = ""
|
||||
return ""
|
||||
}
|
||||
|
||||
// insert only
|
||||
if let proxy {
|
||||
if proxy.hasSuffix(insert) {
|
||||
return storePrecedingShadow(proxy)
|
||||
}
|
||||
return storePrecedingShadow(proxy + insert)
|
||||
}
|
||||
return storePrecedingShadow(precedingShadow + insert)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func storePrecedingShadow(_ value: String) -> String {
|
||||
precedingShadow = String(value.suffix(Self.precedingShadowLimit))
|
||||
return precedingShadow
|
||||
}
|
||||
|
||||
private func trimPrecedingShadow(by count: Int) {
|
||||
guard count > 0 else { return }
|
||||
if precedingShadow.count >= count {
|
||||
precedingShadow.removeLast(count)
|
||||
} else {
|
||||
precedingShadow = ""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shift
|
||||
|
||||
/// Tap cycle: off → one-shot → Caps Lock → off (iOS-style second tap).
|
||||
@@ -536,6 +625,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
shiftHeld = false
|
||||
shiftPrimedByUser = false
|
||||
typedWhileShiftHeld = false
|
||||
precedingShadow = ""
|
||||
}
|
||||
|
||||
private func clearEnglishWordState(keepPrevious: Bool) {
|
||||
|
||||
@@ -158,6 +158,8 @@
|
||||
"mac.styles.error" = "Couldn’t Save Style";
|
||||
"mac.styles.validation" = "Check the name, prompt length, and the 8-style limit.";
|
||||
"mac.styles.name" = "Style name";
|
||||
"mac.styles.allowsAddedEmoji" = "Allow mood emojis";
|
||||
"mac.styles.allowsAddedEmoji.hint" = "When on, polish may add a few emojis that match the draft’s emotion, and keeps them on screen. Off by default.";
|
||||
"mac.styles.prompt" = "Complete prompt";
|
||||
"mac.styles.hint" = "Use {{DICTIONARY}} to place the personal dictionary. System rules are appended automatically.";
|
||||
"mac.section.settings" = "Settings";
|
||||
@@ -262,8 +264,11 @@
|
||||
"mac.settings.translationOff" = "Don't translate";
|
||||
"mac.settings.volcengineAppId" = "APP ID";
|
||||
"mac.settings.volcengineAccessToken" = "Access Token";
|
||||
"mac.settings.volcengineResourceId" = "Resource ID";
|
||||
"mac.settings.volcengineNote" = "Secret Key is not required. Resource ID defaults to volc.seedasr.sauc.duration.";
|
||||
"mac.settings.volcengineApiKey" = "API Key";
|
||||
"mac.settings.volcengineApiKeyMode" = "New API Key auth";
|
||||
"mac.settings.volcengineApiKeyModeSubtitle" = "On for the new console; off keeps APP ID + Access Token.";
|
||||
"mac.settings.volcengineNoteAppToken" = "Legacy console: APP ID + Access Token. Secret Key not required. Resource fixed to Doubao streaming 2.0 (volc.seedasr.sauc.duration).";
|
||||
"mac.settings.volcengineNoteApiKey" = "New console: API Key only. Resource fixed to Doubao streaming 2.0 (volc.seedasr.sauc.duration).";
|
||||
"mac.settings.recognition" = "RECOGNITION METHOD";
|
||||
"mac.settings.cloudEngine" = "Cloud Engine & AI Refinement";
|
||||
"mac.settings.cloudEngineDesc" = "Premium transcription via your provider's API, plus AI grammar and style polishing.";
|
||||
|
||||
@@ -157,6 +157,8 @@
|
||||
"mac.styles.error" = "无法保存风格";
|
||||
"mac.styles.validation" = "请检查名称、提示词长度及 8 个风格的数量上限。";
|
||||
"mac.styles.name" = "风格名称";
|
||||
"mac.styles.allowsAddedEmoji" = "允许按情绪添加 emoji";
|
||||
"mac.styles.allowsAddedEmoji.hint" = "开启后,润色可按原文情绪点缀少量 emoji,并保留上屏。默认关闭。";
|
||||
"mac.styles.prompt" = "完整提示词";
|
||||
"mac.styles.hint" = "使用 {{DICTIONARY}} 指定个人词典位置;系统规则会自动追加。";
|
||||
"mac.section.settings" = "设置";
|
||||
@@ -261,8 +263,11 @@
|
||||
"mac.settings.translationOff" = "不翻译";
|
||||
"mac.settings.volcengineAppId" = "APP ID";
|
||||
"mac.settings.volcengineAccessToken" = "Access Token";
|
||||
"mac.settings.volcengineResourceId" = "Resource ID";
|
||||
"mac.settings.volcengineNote" = "Secret Key 当前无需填写。Resource ID 默认使用 volc.seedasr.sauc.duration。";
|
||||
"mac.settings.volcengineApiKey" = "API Key";
|
||||
"mac.settings.volcengineApiKeyMode" = "使用新版 API Key 鉴权";
|
||||
"mac.settings.volcengineApiKeyModeSubtitle" = "新控制台请打开;已有 AppID + Token 可保持关闭。";
|
||||
"mac.settings.volcengineNoteAppToken" = "旧版控制台:填写 APP ID 与 Access Token。Secret Key 无需填写。识别资源固定为豆包流式 2.0(volc.seedasr.sauc.duration)。";
|
||||
"mac.settings.volcengineNoteApiKey" = "新版控制台:只需填写 API Key。识别资源固定为豆包流式 2.0(volc.seedasr.sauc.duration)。";
|
||||
"mac.settings.recognition" = "识别方式";
|
||||
"mac.settings.cloudEngine" = "云端引擎与 AI 润色";
|
||||
"mac.settings.cloudEngineDesc" = "通过服务商 API 进行高质量转写,并自动润色语法与风格。";
|
||||
|
||||
Reference in New Issue
Block a user