fix: auto-save voice wake words across apps
This commit is contained in:
@@ -10,7 +10,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
|
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Gateway/WebChat: route inbound messages through the unified dispatch pipeline so /new works consistently across WebChat/TUI and channels.
|
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
|
||||||
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
|
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
|
||||||
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
|
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
|
||||||
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
|
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ object WakeWords {
|
|||||||
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun parseIfChanged(input: String, current: List<String>): List<String>? {
|
||||||
|
val parsed = parseCommaSeparated(input)
|
||||||
|
return if (parsed == current) null else parsed
|
||||||
|
}
|
||||||
|
|
||||||
fun sanitize(words: List<String>, defaults: List<String>): List<String> {
|
fun sanitize(words: List<String>, defaults: List<String>): List<String> {
|
||||||
val cleaned =
|
val cleaned =
|
||||||
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
|
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
|
||||||
return cleaned.ifEmpty { defaults }
|
return cleaned.ifEmpty { defaults }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ExpandLess
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
@@ -49,7 +51,10 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -58,6 +63,7 @@ import com.clawdbot.android.LocationMode
|
|||||||
import com.clawdbot.android.MainViewModel
|
import com.clawdbot.android.MainViewModel
|
||||||
import com.clawdbot.android.NodeForegroundService
|
import com.clawdbot.android.NodeForegroundService
|
||||||
import com.clawdbot.android.VoiceWakeMode
|
import com.clawdbot.android.VoiceWakeMode
|
||||||
|
import com.clawdbot.android.WakeWords
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsSheet(viewModel: MainViewModel) {
|
fun SettingsSheet(viewModel: MainViewModel) {
|
||||||
@@ -86,6 +92,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
||||||
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
|
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
var wakeWordsHadFocus by remember { mutableStateOf(false) }
|
||||||
val deviceModel =
|
val deviceModel =
|
||||||
remember {
|
remember {
|
||||||
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||||
@@ -104,6 +112,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
||||||
|
val commitWakeWords = {
|
||||||
|
val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords)
|
||||||
|
if (parsed != null) {
|
||||||
|
viewModel.setWakeWords(parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val permissionLauncher =
|
val permissionLauncher =
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
||||||
@@ -481,25 +495,27 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
value = wakeWordsText,
|
value = wakeWordsText,
|
||||||
onValueChange = setWakeWordsText,
|
onValueChange = setWakeWordsText,
|
||||||
label = { Text("Wake Words (comma-separated)") },
|
label = { Text("Wake Words (comma-separated)") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier =
|
||||||
|
Modifier.fillMaxWidth().onFocusChanged { focusState ->
|
||||||
|
if (focusState.isFocused) {
|
||||||
|
wakeWordsHadFocus = true
|
||||||
|
} else if (wakeWordsHadFocus) {
|
||||||
|
wakeWordsHadFocus = false
|
||||||
|
commitWakeWords()
|
||||||
|
}
|
||||||
|
},
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
keyboardActions =
|
||||||
|
KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
commitWakeWords()
|
||||||
|
focusManager.clearFocus()
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item { Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } }
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
val parsed = com.clawdbot.android.WakeWords.parseCommaSeparated(wakeWordsText)
|
|
||||||
viewModel.setWakeWords(parsed)
|
|
||||||
},
|
|
||||||
enabled = isConnected,
|
|
||||||
) {
|
|
||||||
Text("Save + Sync")
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.clawdbot.android
|
package com.clawdbot.android
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class WakeWordsTest {
|
class WakeWordsTest {
|
||||||
@@ -32,5 +33,18 @@ class WakeWordsTest {
|
|||||||
assertEquals("w1", sanitized.first())
|
assertEquals("w1", sanitized.first())
|
||||||
assertEquals("w${WakeWords.maxWords}", sanitized.last())
|
assertEquals("w${WakeWords.maxWords}", sanitized.last())
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseIfChangedSkipsWhenUnchanged() {
|
||||||
|
val current = listOf("clawd", "claude")
|
||||||
|
val parsed = WakeWords.parseIfChanged(" clawd , claude ", current)
|
||||||
|
assertNull(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseIfChangedReturnsUpdatedList() {
|
||||||
|
val current = listOf("clawd")
|
||||||
|
val parsed = WakeWords.parseIfChanged(" clawd , jarvis ", current)
|
||||||
|
assertEquals(listOf("clawd", "jarvis"), parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
struct VoiceWakeWordsSettingsView: View {
|
struct VoiceWakeWordsSettingsView: View {
|
||||||
@Environment(NodeAppModel.self) private var appModel
|
@Environment(NodeAppModel.self) private var appModel
|
||||||
@State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
|
@State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
|
||||||
|
@FocusState private var focusedTriggerIndex: Int?
|
||||||
@State private var syncTask: Task<Void, Never>?
|
@State private var syncTask: Task<Void, Never>?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -12,6 +14,10 @@ struct VoiceWakeWordsSettingsView: View {
|
|||||||
TextField("Wake word", text: self.binding(for: index))
|
TextField("Wake word", text: self.binding(for: index))
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
|
.focused(self.$focusedTriggerIndex, equals: index)
|
||||||
|
.onSubmit {
|
||||||
|
self.commitTriggerWords()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onDelete(perform: self.removeWords)
|
.onDelete(perform: self.removeWords)
|
||||||
|
|
||||||
@@ -39,17 +45,18 @@ struct VoiceWakeWordsSettingsView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
if self.triggerWords.isEmpty {
|
if self.triggerWords.isEmpty {
|
||||||
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
||||||
|
self.commitTriggerWords()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: self.triggerWords) { _, newValue in
|
.onChange(of: self.focusedTriggerIndex) { oldValue, newValue in
|
||||||
// Keep local voice wake responsive even if the gateway isn't connected yet.
|
guard oldValue != nil, oldValue != newValue else { return }
|
||||||
VoiceWakePreferences.saveTriggerWords(newValue)
|
self.commitTriggerWords()
|
||||||
|
}
|
||||||
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(newValue)
|
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
|
||||||
self.syncTask?.cancel()
|
guard self.focusedTriggerIndex == nil else { return }
|
||||||
self.syncTask = Task { [snapshot, weak appModel = self.appModel] in
|
let updated = VoiceWakePreferences.loadTriggerWords()
|
||||||
try? await Task.sleep(nanoseconds: 650_000_000)
|
if updated != self.triggerWords {
|
||||||
await appModel?.setGlobalWakeWords(snapshot)
|
self.triggerWords = updated
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,6 +70,7 @@ struct VoiceWakeWordsSettingsView: View {
|
|||||||
if self.triggerWords.isEmpty {
|
if self.triggerWords.isEmpty {
|
||||||
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
||||||
}
|
}
|
||||||
|
self.commitTriggerWords()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func binding(for index: Int) -> Binding<String> {
|
private func binding(for index: Int) -> Binding<String> {
|
||||||
@@ -76,4 +84,15 @@ struct VoiceWakeWordsSettingsView: View {
|
|||||||
self.triggerWords[index] = newValue
|
self.triggerWords[index] = newValue
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func commitTriggerWords() {
|
||||||
|
VoiceWakePreferences.saveTriggerWords(self.triggerWords)
|
||||||
|
|
||||||
|
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(self.triggerWords)
|
||||||
|
self.syncTask?.cancel()
|
||||||
|
self.syncTask = Task { [snapshot, weak appModel = self.appModel] in
|
||||||
|
try? await Task.sleep(nanoseconds: 650_000_000)
|
||||||
|
await appModel?.setGlobalWakeWords(snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ enum VoiceWakePreferences {
|
|||||||
|
|
||||||
// Keep defaults aligned with the mac app.
|
// Keep defaults aligned with the mac app.
|
||||||
static let defaultTriggerWords: [String] = ["clawd", "claude"]
|
static let defaultTriggerWords: [String] = ["clawd", "claude"]
|
||||||
|
static let maxWords = 32
|
||||||
|
static let maxWordLength = 64
|
||||||
|
|
||||||
static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? {
|
static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? {
|
||||||
guard let data = payloadJSON.data(using: .utf8) else { return nil }
|
guard let data = payloadJSON.data(using: .utf8) else { return nil }
|
||||||
@@ -30,6 +32,8 @@ enum VoiceWakePreferences {
|
|||||||
let cleaned = words
|
let cleaned = words
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
.filter { !$0.isEmpty }
|
.filter { !$0.isEmpty }
|
||||||
|
.prefix(Self.maxWords)
|
||||||
|
.map { String($0.prefix(Self.maxWordLength)) }
|
||||||
return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned
|
return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ import Testing
|
|||||||
#expect(VoiceWakePreferences.sanitizeTriggerWords(["", " "]) == VoiceWakePreferences.defaultTriggerWords)
|
#expect(VoiceWakePreferences.sanitizeTriggerWords(["", " "]) == VoiceWakePreferences.defaultTriggerWords)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func sanitizeTriggerWordsLimitsWordLength() {
|
||||||
|
let long = String(repeating: "x", count: VoiceWakePreferences.maxWordLength + 5)
|
||||||
|
let cleaned = VoiceWakePreferences.sanitizeTriggerWords(["ok", long])
|
||||||
|
#expect(cleaned[1].count == VoiceWakePreferences.maxWordLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func sanitizeTriggerWordsLimitsWordCount() {
|
||||||
|
let words = (1...VoiceWakePreferences.maxWords + 3).map { "w\($0)" }
|
||||||
|
let cleaned = VoiceWakePreferences.sanitizeTriggerWords(words)
|
||||||
|
#expect(cleaned.count == VoiceWakePreferences.maxWords)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func displayStringUsesSanitizedWords() {
|
@Test func displayStringUsesSanitizedWords() {
|
||||||
#expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude")
|
#expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ let voiceWakeTriggerChimeKey = "clawdbot.voiceWakeTriggerChime"
|
|||||||
let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime"
|
let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime"
|
||||||
let showDockIconKey = "clawdbot.showDockIcon"
|
let showDockIconKey = "clawdbot.showDockIcon"
|
||||||
let defaultVoiceWakeTriggers = ["clawd", "claude"]
|
let defaultVoiceWakeTriggers = ["clawd", "claude"]
|
||||||
|
let voiceWakeMaxWords = 32
|
||||||
|
let voiceWakeMaxWordLength = 64
|
||||||
let voiceWakeMicKey = "clawdbot.voiceWakeMicID"
|
let voiceWakeMicKey = "clawdbot.voiceWakeMicID"
|
||||||
let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName"
|
let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName"
|
||||||
let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID"
|
let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID"
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ func sanitizeVoiceWakeTriggers(_ words: [String]) -> [String] {
|
|||||||
let cleaned = words
|
let cleaned = words
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
.filter { !$0.isEmpty }
|
.filter { !$0.isEmpty }
|
||||||
|
.prefix(voiceWakeMaxWords)
|
||||||
|
.map { String($0.prefix(voiceWakeMaxWordLength)) }
|
||||||
return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned
|
return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ struct VoiceWakeHelpersTests {
|
|||||||
#expect(cleaned == defaultVoiceWakeTriggers)
|
#expect(cleaned == defaultVoiceWakeTriggers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func sanitizeTriggersLimitsWordLength() {
|
||||||
|
let long = String(repeating: "x", count: voiceWakeMaxWordLength + 5)
|
||||||
|
let cleaned = sanitizeVoiceWakeTriggers(["ok", long])
|
||||||
|
#expect(cleaned[1].count == voiceWakeMaxWordLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func sanitizeTriggersLimitsWordCount() {
|
||||||
|
let words = (1...voiceWakeMaxWords + 3).map { "w\($0)" }
|
||||||
|
let cleaned = sanitizeVoiceWakeTriggers(words)
|
||||||
|
#expect(cleaned.count == voiceWakeMaxWords)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func normalizeLocaleStripsCollation() {
|
@Test func normalizeLocaleStripsCollation() {
|
||||||
#expect(normalizeLocaleIdentifier("en_US@collation=phonebook") == "en_US")
|
#expect(normalizeLocaleIdentifier("en_US@collation=phonebook") == "en_US")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user