Files
bot/apps/macos/Sources/Bot/VoiceSessionCoordinator.swift
zeekay bb5ae75492 Rebrand OpenClaw → Hanzo Bot across entire codebase
Complete rename of all OpenClaw/OPENCLAW/openclaw references to
Hanzo Bot/BOT/bot naming convention:

- Env vars: OPENCLAW_* → BOT_* (18 patterns)
- Config: ~/.openclaw/ → ~/.bot/, openclaw.json → bot.json
- Packages: @openclaw/* → @hanzo/bot-*
- Domains: openclaw.ai → hanzo.bot
- Mobile: OpenClawKit → BotKit, bundle IDs ai.openclaw → ai.hanzo.bot
- mDNS: _openclaw-gw._tcp → _bot-gw._tcp
- Docker: CMD hanzo-bot.mjs, BOT_PREFER_PNPM, BOT_DOCKER_APT_PACKAGES
- CI: docker-release.yml adds :latest tag on main branch
2026-02-11 11:13:08 -08:00

135 lines
4.7 KiB
Swift

import AppKit
import Foundation
import Observation
@MainActor
@Observable
final class VoiceSessionCoordinator {
static let shared = VoiceSessionCoordinator()
enum Source: String { case wakeWord, pushToTalk }
struct Session {
let token: UUID
let source: Source
var text: String
var attributed: NSAttributedString?
var isFinal: Bool
var sendChime: VoiceWakeChime
var autoSendDelay: TimeInterval?
}
private let logger = Logger(subsystem: "ai.hanzo.bot", category: "voicewake.coordinator")
private var session: Session?
// MARK: - API
func startSession(
source: Source,
text: String,
attributed: NSAttributedString? = nil,
forwardEnabled: Bool = false) -> UUID
{
let token = UUID()
self.logger.info("coordinator start token=\(token.uuidString) source=\(source.rawValue) len=\(text.count)")
let attributedText = attributed ?? VoiceWakeOverlayController.shared.makeAttributed(from: text)
let session = Session(
token: token,
source: source,
text: text,
attributed: attributedText,
isFinal: false,
sendChime: .none,
autoSendDelay: nil)
self.session = session
VoiceWakeOverlayController.shared.startSession(
token: token,
source: VoiceWakeOverlayController.Source(rawValue: source.rawValue) ?? .wakeWord,
transcript: text,
attributed: attributedText,
forwardEnabled: forwardEnabled,
isFinal: false)
return token
}
func updatePartial(token: UUID, text: String, attributed: NSAttributedString? = nil) {
guard let session, session.token == token else { return }
self.session?.text = text
self.session?.attributed = attributed
VoiceWakeOverlayController.shared.updatePartial(token: token, transcript: text, attributed: attributed)
}
func finalize(
token: UUID,
text: String,
sendChime: VoiceWakeChime,
autoSendAfter: TimeInterval?)
{
guard let session, session.token == token else { return }
self.logger
.info(
"coordinator finalize token=\(token.uuidString) len=\(text.count) autoSendAfter=\(autoSendAfter ?? -1)")
self.session?.text = text
self.session?.isFinal = true
self.session?.sendChime = sendChime
self.session?.autoSendDelay = autoSendAfter
let attributed = VoiceWakeOverlayController.shared.makeAttributed(from: text)
VoiceWakeOverlayController.shared.presentFinal(
token: token,
transcript: text,
autoSendAfter: autoSendAfter,
sendChime: sendChime,
attributed: attributed)
}
func sendNow(token: UUID, reason: String = "explicit") {
guard let session, session.token == token else { return }
let text = session.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else {
self.logger.info("coordinator sendNow \(reason) empty -> dismiss")
VoiceWakeOverlayController.shared.dismiss(token: token, reason: .empty, outcome: .empty)
self.clearSession()
return
}
VoiceWakeOverlayController.shared.beginSendUI(token: token, sendChime: session.sendChime)
Task.detached {
_ = await VoiceWakeForwarder.forward(transcript: text)
}
}
func dismiss(
token: UUID,
reason: VoiceWakeOverlayController.DismissReason,
outcome: VoiceWakeOverlayController.SendOutcome)
{
guard let session, session.token == token else { return }
VoiceWakeOverlayController.shared.dismiss(token: token, reason: reason, outcome: outcome)
self.clearSession()
}
func updateLevel(token: UUID, _ level: Double) {
guard let session, session.token == token else { return }
VoiceWakeOverlayController.shared.updateLevel(token: token, level)
}
func snapshot() -> (token: UUID?, text: String, visible: Bool) {
(self.session?.token, self.session?.text ?? "", VoiceWakeOverlayController.shared.isVisible)
}
// MARK: - Private
private func clearSession() {
self.session = nil
}
/// Overlay dismiss completion callback (manual X, empty, auto-dismiss after send).
/// Ensures the wake-word recognizer is resumed if Voice Wake is enabled.
func overlayDidDismiss(token: UUID?) {
if let token, self.session?.token == token {
self.clearSession()
}
Task { await VoiceWakeRuntime.shared.refresh(state: AppStateStore.shared) }
}
}