mirror of
https://github.com/home-assistant/android
synced 2024-09-06 17:23:32 +00:00
Update Wear Assist with continued conversations, pipelines (#3604)
- Allow continuing conversation for Assist on watches by adding a mic input button at the bottom of the screen - Allow changing pipelines for Assist on watches when on a supported core version - Preparing for pipeline STT and TTS
This commit is contained in:
parent
b7c6be457e
commit
3c706bb7b1
|
@ -3,6 +3,7 @@ package io.homeassistant.companion.android.common.data.integration
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import io.homeassistant.companion.android.common.data.integration.impl.IntegrationRepositoryImpl
|
import io.homeassistant.companion.android.common.data.integration.impl.IntegrationRepositoryImpl
|
||||||
import io.homeassistant.companion.android.common.data.integration.impl.entities.RateLimitResponse
|
import io.homeassistant.companion.android.common.data.integration.impl.entities.RateLimitResponse
|
||||||
|
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineEvent
|
||||||
import io.homeassistant.companion.android.common.data.websocket.impl.entities.GetConfigResponse
|
import io.homeassistant.companion.android.common.data.websocket.impl.entities.GetConfigResponse
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@ -56,7 +57,11 @@ interface IntegrationRepository {
|
||||||
|
|
||||||
suspend fun shouldNotifySecurityWarning(): Boolean
|
suspend fun shouldNotifySecurityWarning(): Boolean
|
||||||
|
|
||||||
suspend fun getAssistResponse(speech: String): String?
|
suspend fun getAssistResponse(
|
||||||
|
text: String,
|
||||||
|
pipelineId: String? = null,
|
||||||
|
conversationId: String? = null
|
||||||
|
): Flow<AssistPipelineEvent>?
|
||||||
}
|
}
|
||||||
|
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
|
|
|
@ -25,6 +25,7 @@ import io.homeassistant.companion.android.common.data.integration.impl.entities.
|
||||||
import io.homeassistant.companion.android.common.data.integration.impl.entities.Template
|
import io.homeassistant.companion.android.common.data.integration.impl.entities.Template
|
||||||
import io.homeassistant.companion.android.common.data.integration.impl.entities.UpdateLocationRequest
|
import io.homeassistant.companion.android.common.data.integration.impl.entities.UpdateLocationRequest
|
||||||
import io.homeassistant.companion.android.common.data.servers.ServerManager
|
import io.homeassistant.companion.android.common.data.servers.ServerManager
|
||||||
|
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineEvent
|
||||||
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineEventType
|
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineEventType
|
||||||
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineIntentEnd
|
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineIntentEnd
|
||||||
import io.homeassistant.companion.android.common.data.websocket.impl.entities.GetConfigResponse
|
import io.homeassistant.companion.android.common.data.websocket.impl.entities.GetConfigResponse
|
||||||
|
@ -33,13 +34,11 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.inject.Named
|
import javax.inject.Named
|
||||||
import kotlin.coroutines.resume
|
|
||||||
|
|
||||||
class IntegrationRepositoryImpl @AssistedInject constructor(
|
class IntegrationRepositoryImpl @AssistedInject constructor(
|
||||||
private val integrationService: IntegrationService,
|
private val integrationService: IntegrationService,
|
||||||
|
@ -533,28 +532,26 @@ class IntegrationRepositoryImpl @AssistedInject constructor(
|
||||||
}?.toList()
|
}?.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getAssistResponse(speech: String): String? {
|
override suspend fun getAssistResponse(text: String, pipelineId: String?, conversationId: String?): Flow<AssistPipelineEvent>? {
|
||||||
return if (server.version?.isAtLeast(2023, 5, 0) == true) {
|
return if (server.version?.isAtLeast(2023, 5, 0) == true) {
|
||||||
var job: Job? = null
|
webSocketRepository.runAssistPipelineForText(text, pipelineId, conversationId)
|
||||||
val response = suspendCancellableCoroutine { cont ->
|
|
||||||
job = ioScope.launch {
|
|
||||||
webSocketRepository.runAssistPipelineForText(speech)?.collect {
|
|
||||||
if (!cont.isActive) return@collect
|
|
||||||
when (it.type) {
|
|
||||||
AssistPipelineEventType.INTENT_END ->
|
|
||||||
cont.resume((it.data as AssistPipelineIntentEnd).intentOutput.response.speech.plain["speech"])
|
|
||||||
AssistPipelineEventType.ERROR,
|
|
||||||
AssistPipelineEventType.RUN_END -> cont.resume(null)
|
|
||||||
else -> { /* Do nothing */ }
|
|
||||||
}
|
|
||||||
} ?: cont.resume(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
job?.cancel()
|
|
||||||
response
|
|
||||||
} else {
|
} else {
|
||||||
val response = webSocketRepository.getConversation(speech)
|
flow {
|
||||||
response?.response?.speech?.plain?.get("speech")
|
emit(AssistPipelineEvent(type = AssistPipelineEventType.RUN_START, data = null))
|
||||||
|
emit(AssistPipelineEvent(type = AssistPipelineEventType.INTENT_START, data = null))
|
||||||
|
val response = webSocketRepository.getConversation(text)
|
||||||
|
if (response != null) {
|
||||||
|
emit(
|
||||||
|
AssistPipelineEvent(
|
||||||
|
type = AssistPipelineEventType.INTENT_END,
|
||||||
|
data = AssistPipelineIntentEnd(response)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
emit(AssistPipelineEvent(type = AssistPipelineEventType.ERROR, data = null))
|
||||||
|
}
|
||||||
|
emit(AssistPipelineEvent(type = AssistPipelineEventType.RUN_END, data = null))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,7 @@ import androidx.activity.viewModels
|
||||||
import androidx.core.content.getSystemService
|
import androidx.core.content.getSystemService
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import io.homeassistant.companion.android.conversation.views.ConversationResultView
|
import io.homeassistant.companion.android.conversation.views.LoadAssistView
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
|
@ -34,7 +34,6 @@ class ConversationActivity : ComponentActivity() {
|
||||||
it?.get(0) ?: ""
|
it?.get(0) ?: ""
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
conversationViewModel.getConversation()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -42,8 +41,29 @@ class ConversationActivity : ComponentActivity() {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
conversationViewModel.checkAssistSupport()
|
val launchIntent = conversationViewModel.onCreate()
|
||||||
if (conversationViewModel.supportsAssist) {
|
if (launchIntent) {
|
||||||
|
launchVoiceInputIntent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
LoadAssistView(
|
||||||
|
conversationViewModel = conversationViewModel,
|
||||||
|
onMicrophoneInput = this::launchVoiceInputIntent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
val pm = applicationContext.getSystemService<PowerManager>()
|
||||||
|
if (pm?.isInteractive == false && conversationViewModel.conversation.size >= 3) {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchVoiceInputIntent() {
|
||||||
val searchIntent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
val searchIntent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||||
putExtra(
|
putExtra(
|
||||||
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
||||||
|
@ -53,17 +73,3 @@ class ConversationActivity : ComponentActivity() {
|
||||||
searchResults.launch(searchIntent)
|
searchResults.launch(searchIntent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setContent {
|
|
||||||
ConversationResultView(conversationViewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPause() {
|
|
||||||
super.onPause()
|
|
||||||
val pm = applicationContext.getSystemService<PowerManager>()
|
|
||||||
if (pm?.isInteractive == false && conversationViewModel.conversationResult.isNotEmpty()) {
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -2,13 +2,21 @@ package io.homeassistant.companion.android.conversation
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import io.homeassistant.companion.android.common.R
|
||||||
import io.homeassistant.companion.android.common.data.prefs.WearPrefsRepository
|
import io.homeassistant.companion.android.common.data.prefs.WearPrefsRepository
|
||||||
import io.homeassistant.companion.android.common.data.servers.ServerManager
|
import io.homeassistant.companion.android.common.data.servers.ServerManager
|
||||||
|
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineError
|
||||||
|
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineEventType
|
||||||
|
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineIntentEnd
|
||||||
|
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineResponse
|
||||||
|
import io.homeassistant.companion.android.conversation.views.AssistMessage
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ -19,58 +27,167 @@ class ConversationViewModel @Inject constructor(
|
||||||
private val wearPrefsRepository: WearPrefsRepository
|
private val wearPrefsRepository: WearPrefsRepository
|
||||||
) : AndroidViewModel(application) {
|
) : AndroidViewModel(application) {
|
||||||
|
|
||||||
var speechResult by mutableStateOf("")
|
private val app = application
|
||||||
private set
|
|
||||||
|
|
||||||
var conversationResult by mutableStateOf("")
|
private var conversationId: String? = null
|
||||||
private set
|
|
||||||
|
|
||||||
var supportsAssist by mutableStateOf(false)
|
|
||||||
private set
|
|
||||||
|
|
||||||
var useAssistPipeline by mutableStateOf(false)
|
var useAssistPipeline by mutableStateOf(false)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var isHapticEnabled = mutableStateOf(false)
|
var allowInput by mutableStateOf(false)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var isRegistered by mutableStateOf(false)
|
var isHapticEnabled by mutableStateOf(false)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var checkSupportProgress by mutableStateOf(true)
|
var currentPipeline by mutableStateOf<AssistPipelineResponse?>(null)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
fun getConversation() {
|
private val _pipelines = mutableStateListOf<AssistPipelineResponse>()
|
||||||
viewModelScope.launch {
|
val pipelines: List<AssistPipelineResponse> = _pipelines
|
||||||
conversationResult =
|
|
||||||
if (serverManager.isRegistered()) {
|
private val startMessage = AssistMessage(application.getString(R.string.assist_how_can_i_assist), isInput = false)
|
||||||
serverManager.integrationRepository().getAssistResponse(speechResult) ?: ""
|
private val _conversation = mutableStateListOf(startMessage)
|
||||||
|
val conversation: List<AssistMessage> = _conversation
|
||||||
|
|
||||||
|
/** @return `true` if the voice input intent should be fired */
|
||||||
|
suspend fun onCreate(): Boolean {
|
||||||
|
val supported = checkAssistSupport()
|
||||||
|
if (!serverManager.isRegistered()) {
|
||||||
|
_conversation.clear()
|
||||||
|
_conversation.add(
|
||||||
|
AssistMessage(app.getString(R.string.not_registered), isInput = false)
|
||||||
|
)
|
||||||
|
} else if (supported == null) { // Couldn't get config
|
||||||
|
_conversation.clear()
|
||||||
|
_conversation.add(
|
||||||
|
AssistMessage(app.getString(R.string.assist_connnect), isInput = false)
|
||||||
|
)
|
||||||
|
} else if (!supported) { // Core too old or missing component
|
||||||
|
val usingPipelines = serverManager.getServer()?.version?.isAtLeast(2023, 5) == true
|
||||||
|
_conversation.clear()
|
||||||
|
_conversation.add(
|
||||||
|
AssistMessage(
|
||||||
|
if (usingPipelines) {
|
||||||
|
app.getString(R.string.no_assist_support, "2023.5", app.getString(R.string.no_assist_support_assist_pipeline))
|
||||||
} else {
|
} else {
|
||||||
""
|
app.getString(R.string.no_assist_support, "2023.1", app.getString(R.string.no_assist_support_conversation))
|
||||||
}
|
},
|
||||||
|
isInput = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if (serverManager.getServer()?.version?.isAtLeast(2023, 5) == true) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
loadPipelines()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun checkAssistSupport() {
|
return setPipeline(null)
|
||||||
checkSupportProgress = true
|
}
|
||||||
isRegistered = serverManager.isRegistered()
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun checkAssistSupport(): Boolean? {
|
||||||
|
isHapticEnabled = wearPrefsRepository.getWearHapticFeedback()
|
||||||
|
if (!serverManager.isRegistered()) return false
|
||||||
|
|
||||||
if (serverManager.isRegistered()) {
|
|
||||||
val config = serverManager.webSocketRepository().getConfig()
|
val config = serverManager.webSocketRepository().getConfig()
|
||||||
val onConversationVersion = serverManager.integrationRepository().isHomeAssistantVersionAtLeast(2023, 1, 0)
|
val onConversationVersion = serverManager.integrationRepository().isHomeAssistantVersionAtLeast(2023, 1, 0)
|
||||||
val onPipelineVersion = serverManager.integrationRepository().isHomeAssistantVersionAtLeast(2023, 5, 0)
|
val onPipelineVersion = serverManager.integrationRepository().isHomeAssistantVersionAtLeast(2023, 5, 0)
|
||||||
|
|
||||||
supportsAssist =
|
useAssistPipeline = onPipelineVersion
|
||||||
|
return if ((onConversationVersion && !onPipelineVersion && config == null) || (onPipelineVersion && config == null)) {
|
||||||
|
null // Version OK but couldn't get config (offline)
|
||||||
|
} else {
|
||||||
(onConversationVersion && !onPipelineVersion && config?.components?.contains("conversation") == true) ||
|
(onConversationVersion && !onPipelineVersion && config?.components?.contains("conversation") == true) ||
|
||||||
(onPipelineVersion && config?.components?.contains("assist_pipeline") == true)
|
(onPipelineVersion && config?.components?.contains("assist_pipeline") == true)
|
||||||
useAssistPipeline = onPipelineVersion
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isHapticEnabled.value = wearPrefsRepository.getWearHapticFeedback()
|
private suspend fun loadPipelines() {
|
||||||
checkSupportProgress = false
|
val pipelines = serverManager.webSocketRepository().getAssistPipelines()
|
||||||
|
pipelines?.let { _pipelines.addAll(it.pipelines) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateSpeechResult(result: String) {
|
fun changePipeline(id: String) = viewModelScope.launch {
|
||||||
speechResult = result
|
if (id == currentPipeline?.id) return@launch
|
||||||
|
setPipeline(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun setPipeline(id: String?): Boolean {
|
||||||
|
val pipeline = if (useAssistPipeline) {
|
||||||
|
_pipelines.firstOrNull { it.id == id } ?: serverManager.webSocketRepository().getAssistPipeline(id)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pipeline != null || !useAssistPipeline) {
|
||||||
|
currentPipeline = pipeline
|
||||||
|
|
||||||
|
_conversation.clear()
|
||||||
|
_conversation.add(startMessage)
|
||||||
|
conversationId = null
|
||||||
|
|
||||||
|
allowInput = true
|
||||||
|
} else {
|
||||||
|
allowInput = false
|
||||||
|
_conversation.clear()
|
||||||
|
_conversation.add(
|
||||||
|
AssistMessage(app.getString(R.string.assist_error), isInput = false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return allowInput // Currently, always launch voice input when setting the pipeline
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSpeechResult(result: String) = runAssistPipeline(result)
|
||||||
|
|
||||||
|
private fun runAssistPipeline(text: String?) {
|
||||||
|
if (text.isNullOrBlank()) return // Voice support is not ready yet
|
||||||
|
|
||||||
|
val userMessage = AssistMessage(text ?: "…", isInput = true)
|
||||||
|
_conversation.add(userMessage)
|
||||||
|
val haMessage = AssistMessage("…", isInput = false)
|
||||||
|
_conversation.add(haMessage)
|
||||||
|
|
||||||
|
var job: Job? = null
|
||||||
|
job = viewModelScope.launch {
|
||||||
|
val flow = serverManager.integrationRepository().getAssistResponse(
|
||||||
|
text = text,
|
||||||
|
pipelineId = currentPipeline?.id,
|
||||||
|
conversationId = conversationId
|
||||||
|
)
|
||||||
|
|
||||||
|
flow?.collect {
|
||||||
|
when (it.type) {
|
||||||
|
AssistPipelineEventType.INTENT_END -> {
|
||||||
|
val data = (it.data as? AssistPipelineIntentEnd)?.intentOutput ?: return@collect
|
||||||
|
conversationId = data.conversationId
|
||||||
|
data.response.speech.plain["speech"]?.let { response ->
|
||||||
|
_conversation.indexOf(haMessage).takeIf { pos -> pos >= 0 }?.let { index ->
|
||||||
|
_conversation[index] = haMessage.copy(message = response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AssistPipelineEventType.RUN_END -> {
|
||||||
|
job?.cancel()
|
||||||
|
}
|
||||||
|
AssistPipelineEventType.ERROR -> {
|
||||||
|
val errorMessage = (it.data as? AssistPipelineError)?.message ?: return@collect
|
||||||
|
_conversation.indexOf(haMessage).takeIf { pos -> pos >= 0 }?.let { index ->
|
||||||
|
_conversation[index] = haMessage.copy(message = errorMessage, isError = true)
|
||||||
|
}
|
||||||
|
job?.cancel()
|
||||||
|
}
|
||||||
|
else -> { /* Do nothing */ }
|
||||||
|
}
|
||||||
|
} ?: run {
|
||||||
|
_conversation.indexOf(haMessage).takeIf { pos -> pos >= 0 }?.let { index ->
|
||||||
|
_conversation[index] = haMessage.copy(message = app.getString(R.string.assist_error), isError = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
package io.homeassistant.companion.android.conversation.views
|
||||||
|
|
||||||
|
data class AssistMessage(
|
||||||
|
val message: String,
|
||||||
|
val isInput: Boolean,
|
||||||
|
val isError: Boolean = false
|
||||||
|
)
|
|
@ -1,19 +1,22 @@
|
||||||
package io.homeassistant.companion.android.conversation.views
|
package io.homeassistant.companion.android.conversation.views
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.AbsoluteRoundedCornerShape
|
import androidx.compose.foundation.shape.AbsoluteRoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
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
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.res.colorResource
|
import androidx.compose.ui.res.colorResource
|
||||||
|
@ -21,23 +24,81 @@ import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Devices
|
import androidx.compose.ui.tooling.preview.Devices
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.wear.compose.material.Button
|
||||||
|
import androidx.wear.compose.material.ButtonDefaults
|
||||||
|
import androidx.wear.compose.material.Chip
|
||||||
|
import androidx.wear.compose.material.ChipDefaults
|
||||||
|
import androidx.wear.compose.material.LocalContentColor
|
||||||
import androidx.wear.compose.material.PositionIndicator
|
import androidx.wear.compose.material.PositionIndicator
|
||||||
import androidx.wear.compose.material.Scaffold
|
import androidx.wear.compose.material.Scaffold
|
||||||
import androidx.wear.compose.material.ScalingLazyColumn
|
import androidx.wear.compose.material.ScalingLazyColumn
|
||||||
import androidx.wear.compose.material.Text
|
import androidx.wear.compose.material.Text
|
||||||
|
import androidx.wear.compose.material.items
|
||||||
import androidx.wear.compose.material.rememberScalingLazyListState
|
import androidx.wear.compose.material.rememberScalingLazyListState
|
||||||
|
import androidx.wear.compose.navigation.SwipeDismissableNavHost
|
||||||
|
import androidx.wear.compose.navigation.composable
|
||||||
|
import androidx.wear.compose.navigation.rememberSwipeDismissableNavController
|
||||||
|
import com.mikepenz.iconics.compose.Image
|
||||||
|
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
|
||||||
import io.homeassistant.companion.android.common.R
|
import io.homeassistant.companion.android.common.R
|
||||||
|
import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineResponse
|
||||||
import io.homeassistant.companion.android.conversation.ConversationViewModel
|
import io.homeassistant.companion.android.conversation.ConversationViewModel
|
||||||
import io.homeassistant.companion.android.home.views.TimeText
|
import io.homeassistant.companion.android.home.views.TimeText
|
||||||
import io.homeassistant.companion.android.theme.WearAppTheme
|
import io.homeassistant.companion.android.theme.WearAppTheme
|
||||||
|
import io.homeassistant.companion.android.views.ListHeader
|
||||||
|
import io.homeassistant.companion.android.views.ThemeLazyColumn
|
||||||
|
|
||||||
|
private const val SCREEN_CONVERSATION = "conversation"
|
||||||
|
private const val SCREEN_PIPELINES = "pipelines"
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoadAssistView(
|
||||||
|
conversationViewModel: ConversationViewModel,
|
||||||
|
onMicrophoneInput: () -> Unit
|
||||||
|
) {
|
||||||
|
WearAppTheme {
|
||||||
|
val swipeDismissableNavController = rememberSwipeDismissableNavController()
|
||||||
|
SwipeDismissableNavHost(
|
||||||
|
navController = swipeDismissableNavController,
|
||||||
|
startDestination = SCREEN_CONVERSATION
|
||||||
|
) {
|
||||||
|
composable(SCREEN_CONVERSATION) {
|
||||||
|
ConversationResultView(
|
||||||
|
conversation = conversationViewModel.conversation,
|
||||||
|
allowInput = conversationViewModel.allowInput,
|
||||||
|
currentPipeline = conversationViewModel.currentPipeline,
|
||||||
|
hapticFeedback = conversationViewModel.isHapticEnabled,
|
||||||
|
onChangePipeline = {
|
||||||
|
swipeDismissableNavController.navigate(SCREEN_PIPELINES)
|
||||||
|
},
|
||||||
|
onMicrophoneInput = onMicrophoneInput
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(SCREEN_PIPELINES) {
|
||||||
|
ConversationPipelinesView(
|
||||||
|
pipelines = conversationViewModel.pipelines,
|
||||||
|
onSelectPipeline = {
|
||||||
|
conversationViewModel.changePipeline(it)
|
||||||
|
swipeDismissableNavController.navigateUp()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ConversationResultView(
|
fun ConversationResultView(
|
||||||
conversationViewModel: ConversationViewModel
|
conversation: List<AssistMessage>,
|
||||||
|
allowInput: Boolean,
|
||||||
|
currentPipeline: AssistPipelineResponse?,
|
||||||
|
hapticFeedback: Boolean,
|
||||||
|
onChangePipeline: () -> Unit,
|
||||||
|
onMicrophoneInput: () -> Unit
|
||||||
) {
|
) {
|
||||||
val scrollState = rememberScalingLazyListState()
|
val scrollState = rememberScalingLazyListState()
|
||||||
|
|
||||||
WearAppTheme {
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
positionIndicator = {
|
positionIndicator = {
|
||||||
if (scrollState.isScrollInProgress) {
|
if (scrollState.isScrollInProgress) {
|
||||||
|
@ -46,45 +107,62 @@ fun ConversationResultView(
|
||||||
},
|
},
|
||||||
timeText = { TimeText(scalingLazyListState = scrollState) }
|
timeText = { TimeText(scalingLazyListState = scrollState) }
|
||||||
) {
|
) {
|
||||||
ScalingLazyColumn(
|
LaunchedEffect(conversation.size) {
|
||||||
state = scrollState,
|
scrollState.scrollToItem(if (allowInput) conversation.size else (conversation.size - 1))
|
||||||
horizontalAlignment = Alignment.Start
|
|
||||||
) {
|
|
||||||
item {
|
|
||||||
Column {
|
|
||||||
Spacer(Modifier.padding(12.dp))
|
|
||||||
SpeechBubble(
|
|
||||||
text = conversationViewModel.speechResult.ifEmpty {
|
|
||||||
when {
|
|
||||||
conversationViewModel.supportsAssist -> stringResource(R.string.no_results)
|
|
||||||
(!conversationViewModel.supportsAssist && !conversationViewModel.checkSupportProgress) ->
|
|
||||||
if (conversationViewModel.useAssistPipeline) {
|
|
||||||
stringResource(R.string.no_assist_support, "2023.5", stringResource(R.string.no_assist_support_assist_pipeline))
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.no_assist_support, "2023.1", stringResource(R.string.no_assist_support_conversation))
|
|
||||||
}
|
}
|
||||||
(!conversationViewModel.isRegistered) -> stringResource(R.string.not_registered)
|
if (hapticFeedback) {
|
||||||
else -> "..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
false
|
|
||||||
)
|
|
||||||
Spacer(Modifier.padding(4.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (conversationViewModel.conversationResult.isNotEmpty()) {
|
|
||||||
item {
|
|
||||||
if (conversationViewModel.isHapticEnabled.value) {
|
|
||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
LaunchedEffect(key1 = "haptic") {
|
LaunchedEffect("${conversation.size}.${conversation.lastOrNull()?.message?.length}") {
|
||||||
haptic.performHapticFeedback(
|
val message = conversation.lastOrNull() ?: return@LaunchedEffect
|
||||||
HapticFeedbackType.LongPress
|
if (conversation.size > 1 && !message.isInput && message.message != "…") {
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeLazyColumn(state = scrollState) {
|
||||||
|
item {
|
||||||
|
if (currentPipeline != null) {
|
||||||
|
val textColor = LocalContentColor.current.copy(alpha = 0.38f) // disabled/hint alpha
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(
|
||||||
|
onClick = { onChangePipeline() },
|
||||||
|
onClickLabel = stringResource(R.string.assist_change_pipeline)
|
||||||
|
)
|
||||||
|
.padding(bottom = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = currentPipeline.name,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
Image(
|
||||||
|
asset = CommunityMaterial.Icon.cmd_chevron_right,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(16.dp)
|
||||||
|
.padding(start = 4.dp),
|
||||||
|
colorFilter = ColorFilter.tint(textColor)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
SpeechBubble(
|
}
|
||||||
text = conversationViewModel.conversationResult,
|
items(conversation) {
|
||||||
true
|
SpeechBubble(text = it.message, isResponse = !it.isInput)
|
||||||
|
}
|
||||||
|
if (allowInput) {
|
||||||
|
item {
|
||||||
|
Button(
|
||||||
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
|
onClick = { onMicrophoneInput() },
|
||||||
|
colors = ButtonDefaults.secondaryButtonColors()
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
asset = CommunityMaterial.Icon3.cmd_microphone,
|
||||||
|
contentDescription = stringResource(R.string.assist_start_listening),
|
||||||
|
colorFilter = ColorFilter.tint(LocalContentColor.current)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -101,7 +179,9 @@ fun SpeechBubble(text: String, isResponse: Boolean) {
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(
|
.padding(
|
||||||
start = if (isResponse) 0.dp else 24.dp,
|
start = if (isResponse) 0.dp else 24.dp,
|
||||||
end = if (isResponse) 24.dp else 0.dp
|
end = if (isResponse) 24.dp else 0.dp,
|
||||||
|
top = 4.dp,
|
||||||
|
bottom = 4.dp
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
|
@ -135,6 +215,28 @@ fun SpeechBubble(text: String, isResponse: Boolean) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ConversationPipelinesView(
|
||||||
|
pipelines: List<AssistPipelineResponse>,
|
||||||
|
onSelectPipeline: (String) -> Unit
|
||||||
|
) {
|
||||||
|
WearAppTheme {
|
||||||
|
ThemeLazyColumn {
|
||||||
|
item {
|
||||||
|
ListHeader(stringResource(R.string.assist_change_pipeline))
|
||||||
|
}
|
||||||
|
items(items = pipelines, key = { it.id }) {
|
||||||
|
Chip(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text(it.name) },
|
||||||
|
onClick = { onSelectPipeline(it.id) },
|
||||||
|
colors = ChipDefaults.secondaryChipColors()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Preview(device = Devices.WEAR_OS_SMALL_ROUND)
|
@Preview(device = Devices.WEAR_OS_SMALL_ROUND)
|
||||||
@Composable
|
@Composable
|
||||||
fun PreviewSpeechBubble() {
|
fun PreviewSpeechBubble() {
|
||||||
|
|
Loading…
Reference in a new issue