feat: add ui.seamColor accent
This commit is contained in:
@@ -4,12 +4,15 @@
|
|||||||
|
|
||||||
### Features
|
### Features
|
||||||
- Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.
|
- Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.
|
||||||
|
- UI: add optional `ui.seamColor` accent to tint the Talk Mode side bubble (macOS/iOS/Android).
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- macOS: Voice Wake now fully tears down the Speech pipeline when disabled (cancel pending restarts, drop stale callbacks) to avoid high CPU in the background.
|
- macOS: Voice Wake now fully tears down the Speech pipeline when disabled (cancel pending restarts, drop stale callbacks) to avoid high CPU in the background.
|
||||||
- macOS menu: add a Talk Mode action alongside the Open Dashboard/Chat/Canvas entries.
|
- macOS menu: add a Talk Mode action alongside the Open Dashboard/Chat/Canvas entries.
|
||||||
- macOS Debug: hide “Restart Gateway” when the app won’t start a local gateway (remote mode / attach-only).
|
- macOS Debug: hide “Restart Gateway” when the app won’t start a local gateway (remote mode / attach-only).
|
||||||
- macOS Talk Mode: orb overlay refresh, ElevenLabs request logging, API key status in settings, and auto-select first voice when none is configured.
|
- macOS Talk Mode: orb overlay refresh, ElevenLabs request logging, API key status in settings, and auto-select first voice when none is configured.
|
||||||
|
- macOS Talk Mode: avoid stuck playback when the audio player never starts (fail-fast + watchdog).
|
||||||
|
- macOS Talk Mode: increase overlay window size so wave rings don’t clip; close button is hover-only and closer to the orb.
|
||||||
- Talk Mode: wait for chat history to surface the assistant reply before starting TTS (macOS/iOS/Android).
|
- Talk Mode: wait for chat history to surface the assistant reply before starting TTS (macOS/iOS/Android).
|
||||||
- Gateway config: inject `talk.apiKey` from `ELEVENLABS_API_KEY`/shell profile so nodes can fetch it on demand.
|
- Gateway config: inject `talk.apiKey` from `ELEVENLABS_API_KEY`/shell profile so nodes can fetch it on demand.
|
||||||
- Canvas A2UI: tag requests with `platform=android|ios|macos` and boost Android canvas background contrast.
|
- Canvas A2UI: tag requests with `platform=android|ios|macos` and boost Android canvas background contrast.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val serverName: StateFlow<String?> = runtime.serverName
|
val serverName: StateFlow<String?> = runtime.serverName
|
||||||
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
||||||
val isForeground: StateFlow<Boolean> = runtime.isForeground
|
val isForeground: StateFlow<Boolean> = runtime.isForeground
|
||||||
|
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
|
||||||
|
|
||||||
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
|
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
|
||||||
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
|
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
|
||||||
|
|||||||
@@ -120,6 +120,9 @@ class NodeRuntime(context: Context) {
|
|||||||
private val _remoteAddress = MutableStateFlow<String?>(null)
|
private val _remoteAddress = MutableStateFlow<String?>(null)
|
||||||
val remoteAddress: StateFlow<String?> = _remoteAddress.asStateFlow()
|
val remoteAddress: StateFlow<String?> = _remoteAddress.asStateFlow()
|
||||||
|
|
||||||
|
private val _seamColorArgb = MutableStateFlow(DEFAULT_SEAM_COLOR_ARGB)
|
||||||
|
val seamColorArgb: StateFlow<Long> = _seamColorArgb.asStateFlow()
|
||||||
|
|
||||||
private val _isForeground = MutableStateFlow(true)
|
private val _isForeground = MutableStateFlow(true)
|
||||||
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
|
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
|
||||||
|
|
||||||
@@ -133,6 +136,8 @@ class NodeRuntime(context: Context) {
|
|||||||
_serverName.value = name
|
_serverName.value = name
|
||||||
_remoteAddress.value = remote
|
_remoteAddress.value = remote
|
||||||
_isConnected.value = true
|
_isConnected.value = true
|
||||||
|
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||||
|
scope.launch { refreshBrandingFromGateway() }
|
||||||
scope.launch { refreshWakeWordsFromGateway() }
|
scope.launch { refreshWakeWordsFromGateway() }
|
||||||
maybeNavigateToA2uiOnConnect()
|
maybeNavigateToA2uiOnConnect()
|
||||||
},
|
},
|
||||||
@@ -155,6 +160,7 @@ class NodeRuntime(context: Context) {
|
|||||||
_serverName.value = null
|
_serverName.value = null
|
||||||
_remoteAddress.value = null
|
_remoteAddress.value = null
|
||||||
_isConnected.value = false
|
_isConnected.value = false
|
||||||
|
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||||
chat.onDisconnected(message)
|
chat.onDisconnected(message)
|
||||||
showLocalCanvasOnDisconnect()
|
showLocalCanvasOnDisconnect()
|
||||||
}
|
}
|
||||||
@@ -618,6 +624,21 @@ class NodeRuntime(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshBrandingFromGateway() {
|
||||||
|
if (!_isConnected.value) return
|
||||||
|
try {
|
||||||
|
val res = session.request("config.get", "{}")
|
||||||
|
val root = json.parseToJsonElement(res).asObjectOrNull()
|
||||||
|
val config = root?.get("config").asObjectOrNull()
|
||||||
|
val ui = config?.get("ui").asObjectOrNull()
|
||||||
|
val raw = ui?.get("seamColor").asStringOrNull()?.trim()
|
||||||
|
val parsed = parseHexColorArgb(raw) ?: return
|
||||||
|
_seamColorArgb.value = parsed
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult {
|
private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult {
|
||||||
if (
|
if (
|
||||||
command.startsWith(ClawdisCanvasCommand.NamespacePrefix) ||
|
command.startsWith(ClawdisCanvasCommand.NamespacePrefix) ||
|
||||||
@@ -901,6 +922,8 @@ class NodeRuntime(context: Context) {
|
|||||||
|
|
||||||
private data class Quad<A, B, C, D>(val first: A, val second: B, val third: C, val fourth: D)
|
private data class Quad<A, B, C, D>(val first: A, val second: B, val third: C, val fourth: D)
|
||||||
|
|
||||||
|
private const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF9EE0FF
|
||||||
|
|
||||||
private const val a2uiReadyCheckJS: String =
|
private const val a2uiReadyCheckJS: String =
|
||||||
"""
|
"""
|
||||||
(() => {
|
(() => {
|
||||||
@@ -955,3 +978,12 @@ private fun JsonElement?.asStringOrNull(): String? =
|
|||||||
is JsonPrimitive -> content
|
is JsonPrimitive -> content
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun parseHexColorArgb(raw: String?): Long? {
|
||||||
|
val trimmed = raw?.trim().orEmpty()
|
||||||
|
if (trimmed.isEmpty()) return null
|
||||||
|
val hex = if (trimmed.startsWith("#")) trimmed.drop(1) else trimmed
|
||||||
|
if (hex.length != 6) return null
|
||||||
|
val rgb = hex.toLongOrNull(16) ?: return null
|
||||||
|
return 0xFF000000L or rgb
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color as ComposeColor
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
@@ -81,6 +83,8 @@ fun RootScreen(viewModel: MainViewModel) {
|
|||||||
val isForeground by viewModel.isForeground.collectAsState()
|
val isForeground by viewModel.isForeground.collectAsState()
|
||||||
val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState()
|
val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState()
|
||||||
val talkEnabled by viewModel.talkEnabled.collectAsState()
|
val talkEnabled by viewModel.talkEnabled.collectAsState()
|
||||||
|
val seamColorArgb by viewModel.seamColorArgb.collectAsState()
|
||||||
|
val seamColor = remember(seamColorArgb) { ComposeColor(seamColorArgb) }
|
||||||
val audioPermissionLauncher =
|
val audioPermissionLauncher =
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
if (granted) viewModel.setTalkEnabled(true)
|
if (granted) viewModel.setTalkEnabled(true)
|
||||||
@@ -225,6 +229,14 @@ fun RootScreen(viewModel: MainViewModel) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Talk mode gets a dedicated side bubble instead of burying it in settings.
|
// Talk mode gets a dedicated side bubble instead of burying it in settings.
|
||||||
|
val baseOverlay = overlayContainerColor()
|
||||||
|
val talkContainer =
|
||||||
|
lerp(
|
||||||
|
baseOverlay,
|
||||||
|
seamColor.copy(alpha = baseOverlay.alpha),
|
||||||
|
if (talkEnabled) 0.35f else 0.22f,
|
||||||
|
)
|
||||||
|
val talkContent = if (talkEnabled) seamColor else overlayIconColor()
|
||||||
OverlayIconButton(
|
OverlayIconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val next = !talkEnabled
|
val next = !talkEnabled
|
||||||
@@ -238,12 +250,12 @@ fun RootScreen(viewModel: MainViewModel) {
|
|||||||
viewModel.setTalkEnabled(false)
|
viewModel.setTalkEnabled(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
containerColor = talkContainer,
|
||||||
|
contentColor = talkContent,
|
||||||
icon = {
|
icon = {
|
||||||
val tint = if (talkEnabled) MaterialTheme.colorScheme.primary else LocalContentColor.current
|
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.RecordVoiceOver,
|
Icons.Default.RecordVoiceOver,
|
||||||
contentDescription = "Talk Mode",
|
contentDescription = "Talk Mode",
|
||||||
tint = tint,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -278,14 +290,16 @@ private enum class Sheet {
|
|||||||
private fun OverlayIconButton(
|
private fun OverlayIconButton(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
icon: @Composable () -> Unit,
|
icon: @Composable () -> Unit,
|
||||||
|
containerColor: ComposeColor? = null,
|
||||||
|
contentColor: ComposeColor? = null,
|
||||||
) {
|
) {
|
||||||
FilledTonalIconButton(
|
FilledTonalIconButton(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = Modifier.size(44.dp),
|
modifier = Modifier.size(44.dp),
|
||||||
colors =
|
colors =
|
||||||
IconButtonDefaults.filledTonalIconButtonColors(
|
IconButtonDefaults.filledTonalIconButtonColors(
|
||||||
containerColor = overlayContainerColor(),
|
containerColor = containerColor ?: overlayContainerColor(),
|
||||||
contentColor = overlayIconColor(),
|
contentColor = contentColor ?: overlayIconColor(),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
icon()
|
icon()
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ final class NodeAppModel {
|
|||||||
var bridgeServerName: String?
|
var bridgeServerName: String?
|
||||||
var bridgeRemoteAddress: String?
|
var bridgeRemoteAddress: String?
|
||||||
var connectedBridgeID: String?
|
var connectedBridgeID: String?
|
||||||
|
var seamColorHex: String?
|
||||||
|
|
||||||
private let bridge = BridgeSession()
|
private let bridge = BridgeSession()
|
||||||
private var bridgeTask: Task<Void, Never>?
|
private var bridgeTask: Task<Void, Never>?
|
||||||
@@ -225,6 +226,7 @@ final class NodeAppModel {
|
|||||||
self.bridgeRemoteAddress = addr
|
self.bridgeRemoteAddress = addr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await self.refreshBrandingFromGateway()
|
||||||
await self.startVoiceWakeSync()
|
await self.startVoiceWakeSync()
|
||||||
await self.showA2UIOnConnectIfNeeded()
|
await self.showA2UIOnConnectIfNeeded()
|
||||||
},
|
},
|
||||||
@@ -264,6 +266,7 @@ final class NodeAppModel {
|
|||||||
self.bridgeServerName = nil
|
self.bridgeServerName = nil
|
||||||
self.bridgeRemoteAddress = nil
|
self.bridgeRemoteAddress = nil
|
||||||
self.connectedBridgeID = nil
|
self.connectedBridgeID = nil
|
||||||
|
self.seamColorHex = nil
|
||||||
self.showLocalCanvasOnDisconnect()
|
self.showLocalCanvasOnDisconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,9 +282,42 @@ final class NodeAppModel {
|
|||||||
self.bridgeServerName = nil
|
self.bridgeServerName = nil
|
||||||
self.bridgeRemoteAddress = nil
|
self.bridgeRemoteAddress = nil
|
||||||
self.connectedBridgeID = nil
|
self.connectedBridgeID = nil
|
||||||
|
self.seamColorHex = nil
|
||||||
self.showLocalCanvasOnDisconnect()
|
self.showLocalCanvasOnDisconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var seamColor: Color {
|
||||||
|
Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let defaultSeamColor = Color(red: 0.62, green: 0.88, blue: 1.0)
|
||||||
|
|
||||||
|
private static func color(fromHex raw: String?) -> Color? {
|
||||||
|
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
|
||||||
|
guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil }
|
||||||
|
let r = Double((value >> 16) & 0xFF) / 255.0
|
||||||
|
let g = Double((value >> 8) & 0xFF) / 255.0
|
||||||
|
let b = Double(value & 0xFF) / 255.0
|
||||||
|
return Color(red: r, green: g, blue: b)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshBrandingFromGateway() async {
|
||||||
|
do {
|
||||||
|
let res = try await self.bridge.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||||
|
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
|
||||||
|
guard let config = json["config"] as? [String: Any] else { return }
|
||||||
|
let ui = config["ui"] as? [String: Any]
|
||||||
|
let raw = (ui?["seamColor"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
await MainActor.run {
|
||||||
|
self.seamColorHex = raw.isEmpty ? nil : raw
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func setGlobalWakeWords(_ words: [String]) async {
|
func setGlobalWakeWords(_ words: [String]) async {
|
||||||
let sanitized = VoiceWakePreferences.sanitizeTriggerWords(words)
|
let sanitized = VoiceWakePreferences.sanitizeTriggerWords(words)
|
||||||
|
|
||||||
|
|||||||
@@ -147,7 +147,9 @@ private struct CanvasContent: View {
|
|||||||
// Talk mode lives on a side bubble so it doesn't get buried in settings.
|
// Talk mode lives on a side bubble so it doesn't get buried in settings.
|
||||||
OverlayButton(
|
OverlayButton(
|
||||||
systemImage: self.appModel.talkMode.isEnabled ? "waveform.circle.fill" : "waveform.circle",
|
systemImage: self.appModel.talkMode.isEnabled ? "waveform.circle.fill" : "waveform.circle",
|
||||||
brighten: self.brightenButtons)
|
brighten: self.brightenButtons,
|
||||||
|
tint: self.appModel.seamColor,
|
||||||
|
isActive: self.appModel.talkMode.isEnabled)
|
||||||
{
|
{
|
||||||
let next = !self.appModel.talkMode.isEnabled
|
let next = !self.appModel.talkMode.isEnabled
|
||||||
self.talkEnabled = next
|
self.talkEnabled = next
|
||||||
@@ -251,13 +253,15 @@ private struct CanvasContent: View {
|
|||||||
private struct OverlayButton: View {
|
private struct OverlayButton: View {
|
||||||
let systemImage: String
|
let systemImage: String
|
||||||
let brighten: Bool
|
let brighten: Bool
|
||||||
|
var tint: Color? = nil
|
||||||
|
var isActive: Bool = false
|
||||||
let action: () -> Void
|
let action: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: self.action) {
|
Button(action: self.action) {
|
||||||
Image(systemName: self.systemImage)
|
Image(systemName: self.systemImage)
|
||||||
.font(.system(size: 16, weight: .semibold))
|
.font(.system(size: 16, weight: .semibold))
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(self.isActive ? (self.tint ?? .primary) : .primary)
|
||||||
.padding(10)
|
.padding(10)
|
||||||
.background {
|
.background {
|
||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
@@ -275,9 +279,26 @@ private struct OverlayButton: View {
|
|||||||
endPoint: .bottomTrailing))
|
endPoint: .bottomTrailing))
|
||||||
.blendMode(.overlay)
|
.blendMode(.overlay)
|
||||||
}
|
}
|
||||||
|
.overlay {
|
||||||
|
if let tint {
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
tint.opacity(self.isActive ? 0.22 : 0.14),
|
||||||
|
tint.opacity(self.isActive ? 0.10 : 0.06),
|
||||||
|
.clear,
|
||||||
|
],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing))
|
||||||
|
.blendMode(.overlay)
|
||||||
|
}
|
||||||
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
.strokeBorder(.white.opacity(self.brighten ? 0.24 : 0.18), lineWidth: 0.5)
|
.strokeBorder(
|
||||||
|
(self.tint ?? .white).opacity(self.isActive ? 0.34 : (self.brighten ? 0.24 : 0.18)),
|
||||||
|
lineWidth: self.isActive ? 0.7 : 0.5)
|
||||||
}
|
}
|
||||||
.shadow(color: .black.opacity(0.35), radius: 12, y: 6)
|
.shadow(color: .black.opacity(0.35), radius: 12, y: 6)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,9 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gateway-provided UI accent color (hex). Optional; clients provide a default.
|
||||||
|
var seamColorHex: String?
|
||||||
|
|
||||||
var iconOverride: IconOverrideSelection {
|
var iconOverride: IconOverrideSelection {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } }
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } }
|
||||||
}
|
}
|
||||||
@@ -226,6 +229,7 @@ final class AppState {
|
|||||||
self.voicePushToTalkEnabled = UserDefaults.standard
|
self.voicePushToTalkEnabled = UserDefaults.standard
|
||||||
.object(forKey: voicePushToTalkEnabledKey) as? Bool ?? false
|
.object(forKey: voicePushToTalkEnabledKey) as? Bool ?? false
|
||||||
self.talkEnabled = UserDefaults.standard.bool(forKey: talkEnabledKey)
|
self.talkEnabled = UserDefaults.standard.bool(forKey: talkEnabledKey)
|
||||||
|
self.seamColorHex = nil
|
||||||
if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool {
|
if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool {
|
||||||
self.heartbeatsEnabled = storedHeartbeats
|
self.heartbeatsEnabled = storedHeartbeats
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -294,6 +294,11 @@ final class ConnectionsStore {
|
|||||||
: nil
|
: nil
|
||||||
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
|
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
|
||||||
self.configLoaded = true
|
self.configLoaded = true
|
||||||
|
|
||||||
|
let ui = snap.config?["ui"]?.dictionaryValue
|
||||||
|
let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
|
||||||
|
|
||||||
let telegram = snap.config?["telegram"]?.dictionaryValue
|
let telegram = snap.config?["telegram"]?.dictionaryValue
|
||||||
self.telegramToken = telegram?["botToken"]?.stringValue ?? ""
|
self.telegramToken = telegram?["botToken"]?.stringValue ?? ""
|
||||||
self.telegramRequireMention = telegram?["requireMention"]?.boolValue ?? true
|
self.telegramRequireMention = telegram?["requireMention"]?.boolValue ?? true
|
||||||
|
|||||||
@@ -537,6 +537,11 @@ actor TalkModeRuntime {
|
|||||||
params: nil,
|
params: nil,
|
||||||
timeoutMs: 8000)
|
timeoutMs: 8000)
|
||||||
let talk = snap.config?["talk"]?.dictionaryValue
|
let talk = snap.config?["talk"]?.dictionaryValue
|
||||||
|
let ui = snap.config?["ui"]?.dictionaryValue
|
||||||
|
let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
await MainActor.run {
|
||||||
|
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
|
||||||
|
}
|
||||||
let voice = talk?["voiceId"]?.stringValue
|
let voice = talk?["voiceId"]?.stringValue
|
||||||
let model = talk?["modelId"]?.stringValue
|
let model = talk?["modelId"]?.stringValue
|
||||||
let outputFormat = talk?["outputFormat"]?.stringValue
|
let outputFormat = talk?["outputFormat"]?.stringValue
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import SwiftUI
|
|||||||
@Observable
|
@Observable
|
||||||
final class TalkOverlayController {
|
final class TalkOverlayController {
|
||||||
static let shared = TalkOverlayController()
|
static let shared = TalkOverlayController()
|
||||||
|
static let overlaySize: CGFloat = 220
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "talk.overlay")
|
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "talk.overlay")
|
||||||
|
|
||||||
@@ -19,9 +20,6 @@ final class TalkOverlayController {
|
|||||||
var model = Model()
|
var model = Model()
|
||||||
private var window: NSPanel?
|
private var window: NSPanel?
|
||||||
private var hostingView: NSHostingView<TalkOverlayView>?
|
private var hostingView: NSHostingView<TalkOverlayView>?
|
||||||
|
|
||||||
private let width: CGFloat = 160
|
|
||||||
private let height: CGFloat = 160
|
|
||||||
private let padding: CGFloat = 8
|
private let padding: CGFloat = 8
|
||||||
|
|
||||||
func present() {
|
func present() {
|
||||||
@@ -84,7 +82,7 @@ final class TalkOverlayController {
|
|||||||
private func ensureWindow() {
|
private func ensureWindow() {
|
||||||
if self.window != nil { return }
|
if self.window != nil { return }
|
||||||
let panel = NSPanel(
|
let panel = NSPanel(
|
||||||
contentRect: NSRect(x: 0, y: 0, width: self.width, height: self.height),
|
contentRect: NSRect(x: 0, y: 0, width: Self.overlaySize, height: Self.overlaySize),
|
||||||
styleMask: [.nonactivatingPanel, .borderless],
|
styleMask: [.nonactivatingPanel, .borderless],
|
||||||
backing: .buffered,
|
backing: .buffered,
|
||||||
defer: false)
|
defer: false)
|
||||||
@@ -109,7 +107,7 @@ final class TalkOverlayController {
|
|||||||
|
|
||||||
private func targetFrame() -> NSRect {
|
private func targetFrame() -> NSRect {
|
||||||
guard let screen = NSScreen.main else { return .zero }
|
guard let screen = NSScreen.main else { return .zero }
|
||||||
let size = NSSize(width: self.width, height: self.height)
|
let size = NSSize(width: Self.overlaySize, height: Self.overlaySize)
|
||||||
let visible = screen.visibleFrame
|
let visible = screen.visibleFrame
|
||||||
let origin = CGPoint(
|
let origin = CGPoint(
|
||||||
x: visible.maxX - size.width - self.padding,
|
x: visible.maxX - size.width - self.padding,
|
||||||
|
|||||||
@@ -2,40 +2,66 @@ import SwiftUI
|
|||||||
|
|
||||||
struct TalkOverlayView: View {
|
struct TalkOverlayView: View {
|
||||||
var controller: TalkOverlayController
|
var controller: TalkOverlayController
|
||||||
@State private var hovering = false
|
@State private var appState = AppStateStore.shared
|
||||||
|
@State private var hoveringWindow = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack {
|
||||||
TalkOrbView(phase: self.controller.model.phase, level: self.controller.model.level)
|
TalkOrbView(
|
||||||
|
phase: self.controller.model.phase,
|
||||||
|
level: self.controller.model.level,
|
||||||
|
accent: self.seamColor)
|
||||||
.frame(width: 96, height: 96)
|
.frame(width: 96, height: 96)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Circle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
TalkModeController.shared.stopSpeaking(reason: .userTap)
|
TalkModeController.shared.stopSpeaking(reason: .userTap)
|
||||||
}
|
}
|
||||||
.padding(26)
|
.overlay(alignment: .topLeading) {
|
||||||
|
Button {
|
||||||
Button {
|
TalkModeController.shared.exitTalkMode()
|
||||||
TalkModeController.shared.exitTalkMode()
|
} label: {
|
||||||
} label: {
|
Image(systemName: "xmark")
|
||||||
Image(systemName: "xmark")
|
.font(.system(size: 10, weight: .bold))
|
||||||
.font(.system(size: 10, weight: .bold))
|
.foregroundStyle(Color.white.opacity(0.95))
|
||||||
.foregroundStyle(Color.white.opacity(self.hovering ? 0.95 : 0.7))
|
.frame(width: 18, height: 18)
|
||||||
.frame(width: 18, height: 18)
|
.background(Color.black.opacity(0.4))
|
||||||
.background(Color.black.opacity(self.hovering ? 0.45 : 0.3))
|
.clipShape(Circle())
|
||||||
.clipShape(Circle())
|
}
|
||||||
}
|
.buttonStyle(.plain)
|
||||||
.buttonStyle(.plain)
|
.contentShape(Circle())
|
||||||
.contentShape(Circle())
|
.offset(x: -10, y: -10)
|
||||||
.padding(4)
|
.opacity(self.hoveringWindow ? 1 : 0)
|
||||||
.onHover { self.hovering = $0 }
|
.animation(.easeOut(duration: 0.12), value: self.hoveringWindow)
|
||||||
|
.allowsHitTesting(self.hoveringWindow)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(width: 160, height: 160, alignment: .center)
|
.frame(width: TalkOverlayController.overlaySize, height: TalkOverlayController.overlaySize, alignment: .center)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onHover { self.hoveringWindow = $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let defaultSeamColor = Color(red: 0.62, green: 0.88, blue: 1.0)
|
||||||
|
|
||||||
|
private var seamColor: Color {
|
||||||
|
Self.color(fromHex: self.appState.seamColorHex) ?? Self.defaultSeamColor
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func color(fromHex raw: String?) -> Color? {
|
||||||
|
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
|
||||||
|
guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil }
|
||||||
|
let r = Double((value >> 16) & 0xFF) / 255.0
|
||||||
|
let g = Double((value >> 8) & 0xFF) / 255.0
|
||||||
|
let b = Double(value & 0xFF) / 255.0
|
||||||
|
return Color(red: r, green: g, blue: b)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct TalkOrbView: View {
|
private struct TalkOrbView: View {
|
||||||
let phase: TalkModePhase
|
let phase: TalkModePhase
|
||||||
let level: Double
|
let level: Double
|
||||||
|
let accent: Color
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TimelineView(.animation) { context in
|
TimelineView(.animation) { context in
|
||||||
@@ -50,7 +76,7 @@ private struct TalkOrbView: View {
|
|||||||
.shadow(color: Color.black.opacity(0.22), radius: 10, x: 0, y: 5)
|
.shadow(color: Color.black.opacity(0.22), radius: 10, x: 0, y: 5)
|
||||||
.scaleEffect(pulse * listenScale)
|
.scaleEffect(pulse * listenScale)
|
||||||
|
|
||||||
TalkWaveRings(phase: phase, level: level, time: t)
|
TalkWaveRings(phase: phase, level: level, time: t, accent: self.accent)
|
||||||
|
|
||||||
if phase == .thinking {
|
if phase == .thinking {
|
||||||
TalkOrbitArcs(time: t)
|
TalkOrbitArcs(time: t)
|
||||||
@@ -61,7 +87,7 @@ private struct TalkOrbView: View {
|
|||||||
|
|
||||||
private var orbGradient: RadialGradient {
|
private var orbGradient: RadialGradient {
|
||||||
RadialGradient(
|
RadialGradient(
|
||||||
colors: [Color.white, Color(red: 0.62, green: 0.88, blue: 1.0)],
|
colors: [Color.white, self.accent],
|
||||||
center: .topLeading,
|
center: .topLeading,
|
||||||
startRadius: 4,
|
startRadius: 4,
|
||||||
endRadius: 52)
|
endRadius: 52)
|
||||||
@@ -72,7 +98,7 @@ private struct TalkWaveRings: View {
|
|||||||
let phase: TalkModePhase
|
let phase: TalkModePhase
|
||||||
let level: Double
|
let level: Double
|
||||||
let time: TimeInterval
|
let time: TimeInterval
|
||||||
private let ringColor = Color(red: 0.82, green: 0.94, blue: 1.0)
|
let accent: Color
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -83,7 +109,7 @@ private struct TalkWaveRings: View {
|
|||||||
let scale = 0.75 + progress * amplitude + (phase == .listening ? level * 0.15 : 0)
|
let scale = 0.75 + progress * amplitude + (phase == .listening ? level * 0.15 : 0)
|
||||||
let alpha = phase == .speaking ? 0.72 : phase == .listening ? 0.58 + level * 0.28 : 0.4
|
let alpha = phase == .speaking ? 0.72 : phase == .listening ? 0.58 + level * 0.28 : 0.4
|
||||||
Circle()
|
Circle()
|
||||||
.stroke(self.ringColor.opacity(alpha - progress * 0.3), lineWidth: 1.6)
|
.stroke(self.accent.opacity(alpha - progress * 0.3), lineWidth: 1.6)
|
||||||
.scaleEffect(scale)
|
.scaleEffect(scale)
|
||||||
.opacity(alpha - progress * 0.6)
|
.opacity(alpha - progress * 0.6)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -462,6 +462,20 @@ Defaults:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `ui` (Appearance)
|
||||||
|
|
||||||
|
Optional accent color used by the native apps for UI chrome (e.g. Talk Mode bubble tint).
|
||||||
|
|
||||||
|
If unset, clients fall back to a muted light-blue.
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
ui: {
|
||||||
|
seamColor: "#FF4500" // hex (RRGGBB or #RRGGBB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### `gateway` (Gateway server mode + bind)
|
### `gateway` (Gateway server mode + bind)
|
||||||
|
|
||||||
Use `gateway.mode` to explicitly declare whether this machine should run the Gateway.
|
Use `gateway.mode` to explicitly declare whether this machine should run the Gateway.
|
||||||
|
|||||||
@@ -346,6 +346,10 @@ export type ClawdisConfig = {
|
|||||||
};
|
};
|
||||||
logging?: LoggingConfig;
|
logging?: LoggingConfig;
|
||||||
browser?: BrowserConfig;
|
browser?: BrowserConfig;
|
||||||
|
ui?: {
|
||||||
|
/** Accent color for Clawdis UI chrome (hex). */
|
||||||
|
seamColor?: string;
|
||||||
|
};
|
||||||
skillsLoad?: SkillsLoadConfig;
|
skillsLoad?: SkillsLoadConfig;
|
||||||
skillsInstall?: SkillsInstallConfig;
|
skillsInstall?: SkillsInstallConfig;
|
||||||
models?: ModelsConfig;
|
models?: ModelsConfig;
|
||||||
@@ -494,6 +498,10 @@ const TranscribeAudioSchema = z
|
|||||||
})
|
})
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
|
const HexColorSchema = z
|
||||||
|
.string()
|
||||||
|
.regex(/^#?[0-9a-fA-F]{6}$/, "expected hex color (RRGGBB)");
|
||||||
|
|
||||||
const SessionSchema = z
|
const SessionSchema = z
|
||||||
.object({
|
.object({
|
||||||
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
|
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
|
||||||
@@ -672,6 +680,11 @@ const ClawdisSchema = z.object({
|
|||||||
attachOnly: z.boolean().optional(),
|
attachOnly: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
ui: z
|
||||||
|
.object({
|
||||||
|
seamColor: HexColorSchema.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
models: ModelsConfigSchema,
|
models: ModelsConfigSchema,
|
||||||
agent: z
|
agent: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
20
src/config/ui-seam-color.test.ts
Normal file
20
src/config/ui-seam-color.test.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { validateConfigObject } from "./config.js";
|
||||||
|
|
||||||
|
describe("ui.seamColor", () => {
|
||||||
|
it("accepts hex colors", () => {
|
||||||
|
const res = validateConfigObject({ ui: { seamColor: "#FF4500" } });
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-hex colors", () => {
|
||||||
|
const res = validateConfigObject({ ui: { seamColor: "lobster" } });
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid hex length", () => {
|
||||||
|
const res = validateConfigObject({ ui: { seamColor: "#FF4500FF" } });
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user