mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-07 18:42:25 +00:00
style: refine mac voice settings layout
This commit is contained in:
@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway: overlap startup logging and plugin-service startup with channel sidecars to reduce restart ready latency while preserving `/readyz` sidecar gating. (#83301) Thanks @samzong.
|
||||
- Plugins/admin-http-rpc: allow trusted admin HTTP RPC clients to start and wait for web QR login flows. (#83259) Thanks @liorb-mountapps.
|
||||
- Mac app: redesign Settings pages with consistent card layouts, cached navigation, cleaner permissions/voice/skills/cron/exec/debug panes, and steadier spacing around the native sidebar.
|
||||
- Mac app: refine Voice & Talk recognition-language and wake-phrase settings so they use the same compact card rows as the rest of Settings.
|
||||
- Skills: rename the repo-local Codex closeout review skill and helper to `autoreview` while preserving the Codex-first fallback behavior.
|
||||
- Skills: add a meme-maker skill for curated template search, local SVG/PNG rendering, Imgflip hosted rendering, and Know Your Meme provenance links.
|
||||
- Skills CLI: allow `openclaw skills install` and `openclaw skills update` to target shared managed skills with `--global`. (#74466) Thanks @Marvae.
|
||||
|
||||
@@ -251,67 +251,67 @@ struct VoiceWakeSettings: View {
|
||||
|
||||
private var triggerTable: some View {
|
||||
SettingsCardGroup("Trigger Words") {
|
||||
HStack {
|
||||
Text("Wake phrases")
|
||||
.font(.callout.weight(.semibold))
|
||||
Spacer()
|
||||
Button {
|
||||
self.addWord()
|
||||
} label: {
|
||||
Label("Add word", systemImage: "plus")
|
||||
}
|
||||
.disabled(self.triggerEntries
|
||||
.contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
|
||||
|
||||
Button("Reset defaults") {
|
||||
self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
|
||||
self.syncTriggerEntriesToState()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 12)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
ForEach(self.$triggerEntries) { $entry in
|
||||
HStack(spacing: 8) {
|
||||
TextField("Wake word", text: $entry.value)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit {
|
||||
self.syncTriggerEntriesToState()
|
||||
}
|
||||
|
||||
Button {
|
||||
self.removeWord(id: entry.id)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Remove trigger word")
|
||||
.frame(width: 24)
|
||||
SettingsCardRow(
|
||||
title: "Wake phrases",
|
||||
subtitle: "Short phrases that start voice wake detection.")
|
||||
{
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
self.addWord()
|
||||
} label: {
|
||||
Label("Add word", systemImage: "plus")
|
||||
}
|
||||
.padding(8)
|
||||
.disabled(self.triggerEntries
|
||||
.contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
|
||||
|
||||
if entry.id != self.triggerEntries.last?.id {
|
||||
Divider()
|
||||
Button("Reset") {
|
||||
self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
|
||||
self.syncTriggerEntriesToState()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading)
|
||||
.background(Color(nsColor: .textBackgroundColor))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color.secondary.opacity(0.25), lineWidth: 1))
|
||||
.padding(.horizontal, 14)
|
||||
|
||||
Text(
|
||||
"OpenClaw reacts when any trigger appears in a transcription. "
|
||||
+ "Keep them short to avoid false positives.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
self.triggerPhraseRows
|
||||
|
||||
TriggerPhraseHelpRow()
|
||||
}
|
||||
}
|
||||
|
||||
private var triggerPhraseRows: some View {
|
||||
Group {
|
||||
if self.triggerEntries.isEmpty {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "text.badge.plus")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 22)
|
||||
Text("No wake phrases configured")
|
||||
.font(.callout.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 12)
|
||||
.padding(.vertical, 14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(self.$triggerEntries) { $entry in
|
||||
TriggerPhraseRow(
|
||||
value: $entry.value,
|
||||
showsDivider: entry.id != self.triggerEntries.last?.id,
|
||||
onSubmit: {
|
||||
self.syncTriggerEntriesToState()
|
||||
},
|
||||
onRemove: {
|
||||
self.removeWord(id: entry.id)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
Divider()
|
||||
.padding(.leading, 14)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,67 +535,79 @@ struct VoiceWakeSettings: View {
|
||||
.frame(width: self.controlWidth)
|
||||
}
|
||||
|
||||
if !self.state.voiceWakeAdditionalLocaleIDs.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Additional languages")
|
||||
.font(.footnote.weight(.semibold))
|
||||
ForEach(
|
||||
Array(self.state.voiceWakeAdditionalLocaleIDs.enumerated()),
|
||||
id: \.offset)
|
||||
{ idx, localeID in
|
||||
HStack(spacing: 8) {
|
||||
Picker("Extra \(idx + 1)", selection: Binding(
|
||||
get: { localeID },
|
||||
set: { newValue in
|
||||
guard self.state
|
||||
.voiceWakeAdditionalLocaleIDs.indices
|
||||
.contains(idx) else { return }
|
||||
self.state
|
||||
.voiceWakeAdditionalLocaleIDs[idx] =
|
||||
newValue
|
||||
})) {
|
||||
ForEach(self.availableLocales.map(\.identifier), id: \.self) { id in
|
||||
Text(self.friendlyName(for: Locale(identifier: id))).tag(id)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 220)
|
||||
|
||||
Button {
|
||||
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return }
|
||||
self.state.voiceWakeAdditionalLocaleIDs.remove(at: idx)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Remove language")
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCardRow(
|
||||
title: "Additional languages",
|
||||
subtitle: self.additionalLanguagesSubtitle,
|
||||
showsDivider: !self.state.voiceWakeAdditionalLocaleIDs.isEmpty)
|
||||
{
|
||||
if self.state.voiceWakeAdditionalLocaleIDs.isEmpty {
|
||||
Button {
|
||||
if let first = availableLocales.first {
|
||||
self.state.voiceWakeAdditionalLocaleIDs.append(first.identifier)
|
||||
}
|
||||
self.addAdditionalLocale()
|
||||
} label: {
|
||||
Label("Add language", systemImage: "plus")
|
||||
Label("Add", systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.availableLocales.isEmpty)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 10)
|
||||
} else {
|
||||
Button {
|
||||
if let first = availableLocales.first {
|
||||
self.state.voiceWakeAdditionalLocaleIDs.append(first.identifier)
|
||||
}
|
||||
} label: {
|
||||
Label("Add additional language", systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(.link)
|
||||
.disabled(self.availableLocales.isEmpty)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
|
||||
if !self.state.voiceWakeAdditionalLocaleIDs.isEmpty {
|
||||
self.additionalLanguageRows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var additionalLanguagesSubtitle: String {
|
||||
if self.state.voiceWakeAdditionalLocaleIDs.isEmpty {
|
||||
return "None configured."
|
||||
}
|
||||
return "Tried after the primary language."
|
||||
}
|
||||
|
||||
private var additionalLanguageRows: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(self.state.voiceWakeAdditionalLocaleIDs.enumerated()), id: \.offset) { idx, localeID in
|
||||
AdditionalLanguageRow(
|
||||
index: idx,
|
||||
selection: self.additionalLocaleBinding(index: idx, fallback: localeID),
|
||||
localeIDs: self.availableLocales.map(\.identifier),
|
||||
localeName: { id in self.friendlyName(for: Locale(identifier: id)) },
|
||||
showsDivider: true,
|
||||
onRemove: {
|
||||
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return }
|
||||
self.state.voiceWakeAdditionalLocaleIDs.remove(at: idx)
|
||||
})
|
||||
}
|
||||
|
||||
SettingsCardRow(title: "Add another language", showsDivider: false) {
|
||||
Button {
|
||||
self.addAdditionalLocale()
|
||||
} label: {
|
||||
Label("Add", systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.availableLocales.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func additionalLocaleBinding(index: Int, fallback: String) -> Binding<String> {
|
||||
Binding(
|
||||
get: {
|
||||
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(index) else { return fallback }
|
||||
return self.state.voiceWakeAdditionalLocaleIDs[index]
|
||||
},
|
||||
set: { newValue in
|
||||
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(index) else { return }
|
||||
self.state.voiceWakeAdditionalLocaleIDs[index] = newValue
|
||||
})
|
||||
}
|
||||
|
||||
private func addAdditionalLocale() {
|
||||
let selected = Set([self.state.voiceWakeLocaleID] + self.state.voiceWakeAdditionalLocaleIDs)
|
||||
let next = self.availableLocales.first { !selected.contains($0.identifier) } ?? self.availableLocales.first
|
||||
if let next {
|
||||
self.state.voiceWakeAdditionalLocaleIDs.append(next.identifier)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -716,6 +728,108 @@ struct VoiceWakeSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct TriggerPhraseRow: View {
|
||||
@Binding var value: String
|
||||
let showsDivider: Bool
|
||||
let onSubmit: () -> Void
|
||||
let onRemove: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "quote.opening")
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 24)
|
||||
|
||||
TextField("Wake phrase", text: self.$value)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.callout.weight(.medium))
|
||||
.frame(maxWidth: 420)
|
||||
.onSubmit(self.onSubmit)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Button(action: self.onRemove) {
|
||||
Image(systemName: "trash")
|
||||
.font(.callout)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 26, height: 26)
|
||||
.contentShape(Rectangle())
|
||||
.help("Remove trigger word")
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.overlay(alignment: .bottom) {
|
||||
if self.showsDivider {
|
||||
Divider()
|
||||
.padding(.leading, 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AdditionalLanguageRow: View {
|
||||
let index: Int
|
||||
@Binding var selection: String
|
||||
let localeIDs: [String]
|
||||
let localeName: (String) -> String
|
||||
let showsDivider: Bool
|
||||
let onRemove: () -> Void
|
||||
|
||||
var body: some View {
|
||||
SettingsCardRow(
|
||||
title: "Language \(self.index + 2)",
|
||||
subtitle: "Fallback recognition language.",
|
||||
showsDivider: self.showsDivider)
|
||||
{
|
||||
HStack(spacing: 10) {
|
||||
Picker("Language \(self.index + 2)", selection: self.$selection) {
|
||||
ForEach(self.localeIDs, id: \.self) { id in
|
||||
Text(self.localeName(id)).tag(id)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 220)
|
||||
|
||||
Button(action: self.onRemove) {
|
||||
Image(systemName: "trash")
|
||||
.font(.callout)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 26, height: 26)
|
||||
.contentShape(Rectangle())
|
||||
.help("Remove language")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TriggerPhraseHelpRow: View {
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "info.circle")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 18)
|
||||
.padding(.top, 1)
|
||||
|
||||
Text("OpenClaw reacts when any trigger appears in a transcription. Keep phrases short to avoid false positives.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 11)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct VoiceWakeSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
|
||||
Reference in New Issue
Block a user