From d1f517357fde6da3ce88f65eb23782ffad9ac7d1 Mon Sep 17 00:00:00 2001 From: Ali Alavi Date: Sat, 26 Sep 2026 07:43:02 +0000 Subject: [PATCH] feat: add Bot/Hermes input commands (/steer, /queue, /cancel) with real-time streaming and multiline formatting --- src/BotCommandProcessor.swift | 83 +++++++++++++++++ src/InputBoxManager.swift | 160 ++++++++++++++++++++++++++++----- src/TextProcessor.swift | 27 ++++++ src/VoiceCommandExecutor.swift | 4 + 4 files changed, 251 insertions(+), 23 deletions(-) create mode 100644 src/BotCommandProcessor.swift diff --git a/src/BotCommandProcessor.swift b/src/BotCommandProcessor.swift new file mode 100644 index 0000000..4a40f5c --- /dev/null +++ b/src/BotCommandProcessor.swift @@ -0,0 +1,83 @@ +import Foundation + +public struct BotCommandResult { + public let command: String // "/steer", "/queue", "/cancel" + public let body: String + + public var formattedText: String { + let cleanBody = body.trimmingCharacters(in: .whitespacesAndNewlines) + if cleanBody.isEmpty { + return command + } + return "\(command)\n\(cleanBody)" + } +} + +public final class BotCommandProcessor { + private static let leadingPunct = CharacterSet(charactersIn: " .,،؛:!؟?\\\"'«»()[]{}—-_") + + // Pattern 1: Steer (/steer) + // Matches: "اسلش اس", "اسلش استیر", "اسلش steer", "slash s", "slash steer", "/s", "/steer" + private static let steerRegex: NSRegularExpression = { + let p = "^(?:(?:اسلش|اصلش|slash|\\/)\\s*(?:استیر|steer|اس|s)|\\/(?:steer|s))(?=[.,،؛!؟?:\\s]|$)[.,،؛!؟?:\\s]*(.*)$" + return try! NSRegularExpression(pattern: p, options: [.caseInsensitive, .dotMatchesLineSeparators]) + }() + + // Pattern 2: Queue (/queue) + // Matches: "اسلش کیو", "اسلش کوئری", "اسلش queue", "slash q", "slash queue", "/q", "/queue" + private static let queueRegex: NSRegularExpression = { + let p = "^(?:(?:اسلش|اصلش|slash|\\/)\\s*(?:کوئری|کیو|queue|q)|\\/(?:queue|q))(?=[.,،؛!؟?:\\s]|$)[.,،؛!؟?:\\s]*(.*)$" + return try! NSRegularExpression(pattern: p, options: [.caseInsensitive, .dotMatchesLineSeparators]) + }() + + // Pattern 3: Cancel (/cancel) + // Matches: "اسلش سی", "اسلش کنسل", "اسلش کنسله", "slash c", "slash cancel", "/c", "/cancel" + private static let cancelRegex: NSRegularExpression = { + let p = "^(?:(?:اسلش|اصلش|slash|\\/)\\s*(?:کنسله|کنسل|cancel|سی|c)|\\/(?:cancel|c))(?=[.,،؛!؟?:\\s]|$)[.,،؛!؟?:\\s]*(.*)$" + return try! NSRegularExpression(pattern: p, options: [.caseInsensitive, .dotMatchesLineSeparators]) + }() + + public static func parse(text: String) -> BotCommandResult? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let range = NSRange(location: 0, length: (trimmed as NSString).length) + + // 1. Check Steer + if let match = steerRegex.firstMatch(in: trimmed, options: [], range: range) { + let body = extractGroup(from: trimmed, match: match, groupIndex: 1) + return BotCommandResult(command: "/steer", body: body) + } + + // 2. Check Queue + if let match = queueRegex.firstMatch(in: trimmed, options: [], range: range) { + let body = extractGroup(from: trimmed, match: match, groupIndex: 1) + return BotCommandResult(command: "/queue", body: body) + } + + // 3. Check Cancel + if let match = cancelRegex.firstMatch(in: trimmed, options: [], range: range) { + let body = extractGroup(from: trimmed, match: match, groupIndex: 1) + return BotCommandResult(command: "/cancel", body: body) + } + + return nil + } + + private static func extractGroup(from str: String, match: NSTextCheckingResult, groupIndex: Int) -> String { + guard groupIndex < match.numberOfRanges else { return "" } + let r = match.range(at: groupIndex) + guard r.location != NSNotFound else { return "" } + let rawGroup = (str as NSString).substring(with: r) + return rawGroup.trimmingCharacters(in: leadingPunct) + } + + public static func isBotCommand(text: String) -> Bool { + return parse(text: text) != nil + } + + public static func formatIfBotCommand(text: String) -> String? { + guard let res = parse(text: text) else { return nil } + return res.formattedText + } +} diff --git a/src/InputBoxManager.swift b/src/InputBoxManager.swift index 48d8602..19f9fb6 100644 --- a/src/InputBoxManager.swift +++ b/src/InputBoxManager.swift @@ -6,6 +6,9 @@ public final class InputBoxManager { private let lock = NSLock() private var accumulatedStreamedText: String = "" + private var pendingPrefixBuffer: String = "" + private var isPrefixResolved: Bool = false + private var needsNewlineBeforeNextDelta: Bool = false private let pasteQueue = DispatchQueue(label: "com.soniox.pasteQueue", qos: .userInteractive) private init() {} @@ -14,6 +17,9 @@ public final class InputBoxManager { public func resetSession() { lock.lock() accumulatedStreamedText = "" + pendingPrefixBuffer = "" + isPrefixResolved = false + needsNewlineBeforeNextDelta = false lock.unlock() } @@ -21,7 +27,9 @@ public final class InputBoxManager { public var hasStreamedText: Bool { lock.lock() defer { lock.unlock() } - return !accumulatedStreamedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let s = accumulatedStreamedText.trimmingCharacters(in: .whitespacesAndNewlines) + let p = pendingPrefixBuffer.trimmingCharacters(in: .whitespacesAndNewlines) + return !s.isEmpty || !p.isEmpty } /// Prepares and guarantees that an input box is focused before recording starts. @@ -68,17 +76,115 @@ public final class InputBoxManager { guard !text.isEmpty else { return } lock.lock() - accumulatedStreamedText += text - lock.unlock() + if isPrefixResolved { + var textToPaste = text + if needsNewlineBeforeNextDelta { + needsNewlineBeforeNextDelta = false + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + textToPaste = "\n" + trimmed + } + } + accumulatedStreamedText += textToPaste + lock.unlock() + + pasteQueue.async { [weak self] in + guard let self = self else { return } + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(textToPaste, forType: .string) + self.postCleanCmdV() + usleep(10000) // Ultra-fast 10ms pacing between streaming chunks + } + return + } - pasteQueue.async { [weak self] in - guard let self = self else { return } - let pb = NSPasteboard.general - pb.clearContents() - pb.setString(text, forType: .string) - self.postCleanCmdV() - usleep(10000) // Ultra-fast 10ms pacing between streaming chunks + // Not resolved yet: buffer to check if speech starts with a slash command + pendingPrefixBuffer += text + let trimmed = pendingPrefixBuffer.trimmingCharacters(in: .whitespacesAndNewlines) + let words = trimmed.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty } + + guard let firstWord = words.first else { + lock.unlock() + return + } + + let slashTriggers = ["اسلش", "اصلش", "slash", "/", "\\"] + let isSlashPrefix = slashTriggers.contains { firstWord.lowercased().hasPrefix($0) } + + if !isSlashPrefix { + // Normal speech: flush buffer immediately and continue normal streaming! + isPrefixResolved = true + let toFlush = pendingPrefixBuffer + pendingPrefixBuffer = "" + accumulatedStreamedText += toFlush + lock.unlock() + + pasteQueue.async { [weak self] in + guard let self = self else { return } + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(toFlush, forType: .string) + self.postCleanCmdV() + usleep(10000) + } + return + } + + // Starts with slash trigger: check if it matches a Bot Command (/steer, /queue, /cancel) + if let botRes = BotCommandProcessor.parse(text: trimmed) { + isPrefixResolved = true + var textToPaste = botRes.command + if !botRes.body.isEmpty { + textToPaste += "\n" + botRes.body + needsNewlineBeforeNextDelta = false + } else { + needsNewlineBeforeNextDelta = true + } + + pendingPrefixBuffer = "" + accumulatedStreamedText = textToPaste + lock.unlock() + + pasteQueue.async { [weak self] in + guard let self = self else { return } + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(textToPaste, forType: .string) + self.postCleanCmdV() + usleep(10000) + } + return } + + // If 2 words have accumulated and it's NOT a bot command: + if words.count >= 2 { + isPrefixResolved = true + // If it's an executive voice command, do NOT stream it to the input box! + if SonioxSettings.shared.voiceCommands && VoiceCommandExecutor.shared.isSlashCommand(text: trimmed) { + pendingPrefixBuffer = "" + lock.unlock() + return + } + + // Otherwise, it was just regular speech starting with "اسلش..." + let toFlush = pendingPrefixBuffer + pendingPrefixBuffer = "" + accumulatedStreamedText += toFlush + lock.unlock() + + pasteQueue.async { [weak self] in + guard let self = self else { return } + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(toFlush, forType: .string) + self.postCleanCmdV() + usleep(10000) + } + return + } + + lock.unlock() } /// Commits the final speech result: ensures 100% complete text in input box & clipboard @@ -94,6 +200,16 @@ public final class InputBoxManager { pb.setString(textWithTrailing, forType: .string) lock.lock() + // Flush any pending buffer if recording ended before prefix resolution + if !isPrefixResolved && !pendingPrefixBuffer.isEmpty { + let pending = pendingPrefixBuffer + pendingPrefixBuffer = "" + if let botRes = BotCommandProcessor.parse(text: pending) { + accumulatedStreamedText = botRes.formattedText + } else if !(SonioxSettings.shared.voiceCommands && VoiceCommandExecutor.shared.isSlashCommand(text: pending)) { + accumulatedStreamedText += pending + } + } let streamed = accumulatedStreamedText accumulatedStreamedText = "" lock.unlock() @@ -109,26 +225,24 @@ public final class InputBoxManager { return } - // Case 2: Text was streamed live - // Check if there are trailing words recognized only at the very end - if trimmed.count > cleanStreamed.count { - if trimmed.hasPrefix(cleanStreamed) { - let suffix = String(trimmed.dropFirst(cleanStreamed.count)) - let suffixToPaste = addTrailingSpace ? (suffix + " ") : suffix - let pb = NSPasteboard.general - pb.clearContents() - pb.setString(suffixToPaste, forType: .string) - self.postCleanCmdV() - } else if addTrailingSpace { + // Case 2: Exactly matches streamed text + if cleanStreamed == trimmed { + if addTrailingSpace { let pb = NSPasteboard.general pb.clearContents() pb.setString(" ", forType: .string) self.postCleanCmdV() } - } else if addTrailingSpace { + return + } + + // Case 3: Streamed prefix is prefix of trimmed final text + if trimmed.count > cleanStreamed.count && trimmed.hasPrefix(cleanStreamed) { + let suffix = String(trimmed.dropFirst(cleanStreamed.count)) + let suffixToPaste = addTrailingSpace ? (suffix + " ") : suffix let pb = NSPasteboard.general pb.clearContents() - pb.setString(" ", forType: .string) + pb.setString(suffixToPaste, forType: .string) self.postCleanCmdV() } } diff --git a/src/TextProcessor.swift b/src/TextProcessor.swift index caee9d7..ce5dfa9 100644 --- a/src/TextProcessor.swift +++ b/src/TextProcessor.swift @@ -9,6 +9,33 @@ public final class TextProcessor { var result = text.trimmingCharacters(in: .whitespacesAndNewlines) if result.isEmpty { return "" } + // 0. Check for Hermes / Bot Messenger Commands (/steer, /queue, /cancel) + if let botCmd = BotCommandProcessor.parse(text: result) { + let processedBody: String + if !botCmd.body.isEmpty { + var bodyText = botCmd.body + if SonioxSettings.shared.voicePunctuation { + bodyText = applyVoicePunctuation(bodyText) + } + if SonioxSettings.shared.domainVocabulary { + bodyText = applyDomainVocabulary(bodyText) + } + bodyText = applyNumberFormatting(bodyText, format: SonioxSettings.shared.numberFormat) + if SonioxSettings.shared.normalizeText { + bodyText = normalizeText(bodyText) + } + processedBody = bodyText.trimmingCharacters(in: .whitespacesAndNewlines) + } else { + processedBody = "" + } + + if processedBody.isEmpty { + return botCmd.command + } else { + return "\(botCmd.command)\n\(processedBody)" + } + } + // 1. Voice Punctuation Commands if SonioxSettings.shared.voicePunctuation { result = applyVoicePunctuation(result) diff --git a/src/VoiceCommandExecutor.swift b/src/VoiceCommandExecutor.swift index 5217f68..2f1c9cd 100644 --- a/src/VoiceCommandExecutor.swift +++ b/src/VoiceCommandExecutor.swift @@ -196,6 +196,10 @@ public final class VoiceCommandExecutor { } public func isSlashCommand(text: String) -> Bool { + // STRICT: Hermes / Bot Messenger commands (/steer, /queue, /cancel) are input box text, NOT executive commands! + if BotCommandProcessor.isBotCommand(text: text) { + return false + } return getSlashWordPosition(text: text) != nil }