Browse Source

feat: add Bot/Hermes input commands (/steer, /queue, /cancel) with real-time streaming and multiline formatting

main
Ali Alavi 2 weeks ago
parent
commit
d1f517357f
  1. 83
      src/BotCommandProcessor.swift
  2. 146
      src/InputBoxManager.swift
  3. 27
      src/TextProcessor.swift
  4. 4
      src/VoiceCommandExecutor.swift

83
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
}
}

146
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
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(text, forType: .string)
pb.setString(textToPaste, forType: .string)
self.postCleanCmdV()
usleep(10000) // Ultra-fast 10ms pacing between streaming chunks
}
return
}
// 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()
}
}

27
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)

4
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
}

Loading…
Cancel
Save