fix: resolve VoiceModeManager race conditions and add server heartbeat
Fix five issues in VoiceModeManager: replace CoroutineScope extension
with coroutineScope{} (#5), move initialIds snapshot into collectorJob
to eliminate race window (#3), add select+onTimeout(30s) to prevent
permanent blocking on server death (#1/#2), propagate null statusText
(#4), and fix break-in-lambda compile error.
Add 10s heartbeat goroutine in runAgentLoop to keep voice mode alive
during long tool executions.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f6da055421
commit
d89c153afa
30 changed files with 1494 additions and 48 deletions
|
|
@ -70,6 +70,7 @@ dependencies {
|
|||
implementation(libs.activity.compose)
|
||||
implementation(libs.core.ktx)
|
||||
implementation(libs.lifecycle.runtime.compose)
|
||||
implementation(libs.navigation.compose)
|
||||
|
||||
implementation(libs.koin.android)
|
||||
implementation(libs.koin.compose)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,13 @@
|
|||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.TTS_SERVICE" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".PicoClawApp"
|
||||
|
|
|
|||
|
|
@ -4,8 +4,13 @@ import android.os.Bundle
|
|||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import io.picoclaw.android.core.ui.theme.PicoClawTheme
|
||||
import io.picoclaw.android.feature.chat.screen.ChatScreen
|
||||
import io.picoclaw.android.feature.chat.screen.SettingsScreen
|
||||
import io.picoclaw.android.navigation.NavRoutes
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
|
@ -13,7 +18,19 @@ class MainActivity : ComponentActivity() {
|
|||
enableEdgeToEdge()
|
||||
setContent {
|
||||
PicoClawTheme {
|
||||
ChatScreen()
|
||||
val navController = rememberNavController()
|
||||
NavHost(navController = navController, startDestination = NavRoutes.CHAT) {
|
||||
composable(NavRoutes.CHAT) {
|
||||
ChatScreen(
|
||||
onNavigateToSettings = { navController.navigate(NavRoutes.SETTINGS) }
|
||||
)
|
||||
}
|
||||
composable(NavRoutes.SETTINGS) {
|
||||
SettingsScreen(
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ import io.picoclaw.android.core.data.local.AppDatabase
|
|||
import io.picoclaw.android.core.data.local.ImageFileStorage
|
||||
import io.picoclaw.android.core.data.remote.WebSocketClient
|
||||
import io.picoclaw.android.core.data.repository.ChatRepositoryImpl
|
||||
import io.picoclaw.android.core.data.repository.TtsCatalogRepositoryImpl
|
||||
import io.picoclaw.android.core.data.repository.TtsSettingsRepositoryImpl
|
||||
import io.picoclaw.android.core.domain.repository.ChatRepository
|
||||
import io.picoclaw.android.core.domain.repository.TtsCatalogRepository
|
||||
import io.picoclaw.android.core.domain.repository.TtsSettingsRepository
|
||||
import io.picoclaw.android.core.domain.usecase.ConnectChatUseCase
|
||||
import io.picoclaw.android.core.domain.usecase.DisconnectChatUseCase
|
||||
import io.picoclaw.android.core.domain.usecase.LoadMoreMessagesUseCase
|
||||
|
|
@ -17,6 +21,10 @@ import io.picoclaw.android.core.domain.usecase.ObserveMessagesUseCase
|
|||
import io.picoclaw.android.core.domain.usecase.ObserveStatusUseCase
|
||||
import io.picoclaw.android.core.domain.usecase.SendMessageUseCase
|
||||
import io.picoclaw.android.feature.chat.ChatViewModel
|
||||
import io.picoclaw.android.feature.chat.SettingsViewModel
|
||||
import io.picoclaw.android.feature.chat.voice.SpeechRecognizerWrapper
|
||||
import io.picoclaw.android.feature.chat.voice.TextToSpeechWrapper
|
||||
import io.picoclaw.android.feature.chat.voice.VoiceModeManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
|
@ -67,6 +75,10 @@ val appModule = module {
|
|||
|
||||
// Repository
|
||||
single<ChatRepository> { ChatRepositoryImpl(get(), get(), get(), get()) }
|
||||
single<TtsSettingsRepository> { TtsSettingsRepositoryImpl(androidContext()) }
|
||||
single<TtsCatalogRepository> {
|
||||
TtsCatalogRepositoryImpl(androidContext(), get<TtsSettingsRepository>().ttsConfig, get())
|
||||
}
|
||||
|
||||
// UseCases
|
||||
factory { SendMessageUseCase(get()) }
|
||||
|
|
@ -77,6 +89,12 @@ val appModule = module {
|
|||
factory { ConnectChatUseCase(get()) }
|
||||
factory { DisconnectChatUseCase(get()) }
|
||||
|
||||
// Voice
|
||||
factory { SpeechRecognizerWrapper(androidContext()) }
|
||||
single { TextToSpeechWrapper(androidContext(), get<TtsSettingsRepository>().ttsConfig) }
|
||||
single { VoiceModeManager(get(), get(), get(), get(), get()) }
|
||||
|
||||
// ViewModel
|
||||
viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get()) }
|
||||
viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get(), get()) }
|
||||
viewModel { SettingsViewModel(get(), get(), get()) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
package io.picoclaw.android.navigation
|
||||
|
||||
object NavRoutes {
|
||||
const val CHAT = "chat"
|
||||
const val SETTINGS = "settings"
|
||||
}
|
||||
|
|
@ -32,4 +32,5 @@ dependencies {
|
|||
ksp(libs.room.compiler)
|
||||
|
||||
implementation(libs.coroutines.android)
|
||||
implementation(libs.datastore.preferences)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
package io.picoclaw.android.core.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.speech.tts.TextToSpeech
|
||||
import io.picoclaw.android.core.domain.model.TtsConfig
|
||||
import io.picoclaw.android.core.domain.model.TtsEngineInfo
|
||||
import io.picoclaw.android.core.domain.model.TtsVoiceInfo
|
||||
import io.picoclaw.android.core.domain.repository.TtsCatalogRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
class TtsCatalogRepositoryImpl(
|
||||
private val context: Context,
|
||||
private val ttsConfigFlow: Flow<TtsConfig>,
|
||||
private val scope: CoroutineScope
|
||||
) : TtsCatalogRepository {
|
||||
|
||||
private val mutex = Mutex()
|
||||
private var tts: TextToSpeech? = null
|
||||
private var initialized = false
|
||||
private var currentEnginePackage: String? = null
|
||||
|
||||
private val _availableEngines = MutableStateFlow<List<TtsEngineInfo>>(emptyList())
|
||||
override val availableEngines: StateFlow<List<TtsEngineInfo>> = _availableEngines.asStateFlow()
|
||||
|
||||
private val _availableVoices = MutableStateFlow<List<TtsVoiceInfo>>(emptyList())
|
||||
override val availableVoices: StateFlow<List<TtsVoiceInfo>> = _availableVoices.asStateFlow()
|
||||
|
||||
init {
|
||||
initTtsEngine(null)
|
||||
|
||||
scope.launch {
|
||||
ttsConfigFlow
|
||||
.distinctUntilChangedBy { it.enginePackageName }
|
||||
.collect { config ->
|
||||
mutex.withLock {
|
||||
if (config.enginePackageName != currentEnginePackage) {
|
||||
initTtsEngine(config.enginePackageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initTtsEngine(enginePackageName: String?) {
|
||||
tts?.shutdown()
|
||||
initialized = false
|
||||
currentEnginePackage = enginePackageName
|
||||
|
||||
val listener = TextToSpeech.OnInitListener { status ->
|
||||
if (status == TextToSpeech.SUCCESS) {
|
||||
initialized = true
|
||||
loadAvailableEngines()
|
||||
loadAvailableVoices()
|
||||
}
|
||||
}
|
||||
|
||||
tts = if (enginePackageName != null) {
|
||||
TextToSpeech(context, listener, enginePackageName)
|
||||
} else {
|
||||
TextToSpeech(context, listener)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAvailableEngines() {
|
||||
val engine = tts ?: return
|
||||
_availableEngines.value = engine.engines.map { info ->
|
||||
TtsEngineInfo(
|
||||
packageName = info.name,
|
||||
label = info.label
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAvailableVoices() {
|
||||
val engine = tts ?: return
|
||||
val voices = engine.voices ?: return
|
||||
_availableVoices.value = voices
|
||||
.filter { !it.isNetworkConnectionRequired }
|
||||
.sortedBy { it.locale.displayName }
|
||||
.map { voice ->
|
||||
TtsVoiceInfo(
|
||||
name = voice.name,
|
||||
displayLabel = "${voice.locale.displayName} - ${voice.name}",
|
||||
locale = voice.locale.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package io.picoclaw.android.core.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.floatPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import io.picoclaw.android.core.domain.model.TtsConfig
|
||||
import io.picoclaw.android.core.domain.repository.TtsSettingsRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.ttsDataStore: DataStore<Preferences> by preferencesDataStore(name = "tts_settings")
|
||||
|
||||
class TtsSettingsRepositoryImpl(
|
||||
private val context: Context
|
||||
) : TtsSettingsRepository {
|
||||
|
||||
private object Keys {
|
||||
val ENGINE = stringPreferencesKey("engine_package")
|
||||
val VOICE_NAME = stringPreferencesKey("voice_name")
|
||||
val SPEECH_RATE = floatPreferencesKey("speech_rate")
|
||||
val PITCH = floatPreferencesKey("pitch")
|
||||
}
|
||||
|
||||
override val ttsConfig: Flow<TtsConfig> = context.ttsDataStore.data.map { prefs ->
|
||||
TtsConfig(
|
||||
enginePackageName = prefs[Keys.ENGINE],
|
||||
voiceName = prefs[Keys.VOICE_NAME],
|
||||
speechRate = prefs[Keys.SPEECH_RATE] ?: 1.0f,
|
||||
pitch = prefs[Keys.PITCH] ?: 1.0f
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun updateEngine(packageName: String?) {
|
||||
context.ttsDataStore.edit { prefs ->
|
||||
if (packageName != null) prefs[Keys.ENGINE] = packageName
|
||||
else prefs.remove(Keys.ENGINE)
|
||||
// エンジン変更時は音声選択をリセット
|
||||
prefs.remove(Keys.VOICE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateVoiceName(voiceName: String?) {
|
||||
context.ttsDataStore.edit { prefs ->
|
||||
if (voiceName != null) prefs[Keys.VOICE_NAME] = voiceName
|
||||
else prefs.remove(Keys.VOICE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateSpeechRate(rate: Float) {
|
||||
context.ttsDataStore.edit { it[Keys.SPEECH_RATE] = rate.coerceIn(0.5f, 2.0f) }
|
||||
}
|
||||
|
||||
override suspend fun updatePitch(pitch: Float) {
|
||||
context.ttsDataStore.edit { it[Keys.PITCH] = pitch.coerceIn(0.5f, 2.0f) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package io.picoclaw.android.core.domain.model
|
||||
|
||||
data class TtsConfig(
|
||||
val enginePackageName: String? = null,
|
||||
val voiceName: String? = null,
|
||||
val speechRate: Float = 1.0f,
|
||||
val pitch: Float = 1.0f
|
||||
)
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package io.picoclaw.android.core.domain.model
|
||||
|
||||
data class TtsEngineInfo(
|
||||
val packageName: String,
|
||||
val label: String
|
||||
)
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package io.picoclaw.android.core.domain.model
|
||||
|
||||
data class TtsVoiceInfo(
|
||||
val name: String,
|
||||
val displayLabel: String,
|
||||
val locale: String
|
||||
)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package io.picoclaw.android.core.domain.model
|
||||
|
||||
enum class VoicePhase {
|
||||
IDLE, LISTENING, SENDING, THINKING, SPEAKING, ERROR
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package io.picoclaw.android.core.domain.repository
|
||||
|
||||
import io.picoclaw.android.core.domain.model.TtsEngineInfo
|
||||
import io.picoclaw.android.core.domain.model.TtsVoiceInfo
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface TtsCatalogRepository {
|
||||
val availableEngines: StateFlow<List<TtsEngineInfo>>
|
||||
val availableVoices: StateFlow<List<TtsVoiceInfo>>
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package io.picoclaw.android.core.domain.repository
|
||||
|
||||
import io.picoclaw.android.core.domain.model.TtsConfig
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface TtsSettingsRepository {
|
||||
val ttsConfig: Flow<TtsConfig>
|
||||
suspend fun updateEngine(packageName: String?)
|
||||
suspend fun updateVoiceName(voiceName: String?)
|
||||
suspend fun updateSpeechRate(rate: Float)
|
||||
suspend fun updatePitch(pitch: Float)
|
||||
}
|
||||
|
|
@ -10,4 +10,6 @@ sealed interface ChatEvent {
|
|||
data object OnLoadMore : ChatEvent
|
||||
data class OnError(val message: String) : ChatEvent
|
||||
data object OnErrorDismissed : ChatEvent
|
||||
data object OnVoiceModeStart : ChatEvent
|
||||
data object OnVoiceModeStop : ChatEvent
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package io.picoclaw.android.feature.chat
|
|||
import io.picoclaw.android.core.domain.model.ChatMessage
|
||||
import io.picoclaw.android.core.domain.model.ConnectionState
|
||||
import io.picoclaw.android.core.domain.model.ImageAttachment
|
||||
import io.picoclaw.android.feature.chat.voice.VoiceModeState
|
||||
|
||||
data class ChatUiState(
|
||||
val messages: List<ChatMessage> = emptyList(),
|
||||
|
|
@ -12,5 +13,6 @@ data class ChatUiState(
|
|||
val isLoadingMore: Boolean = false,
|
||||
val canLoadMore: Boolean = true,
|
||||
val error: String? = null,
|
||||
val statusLabel: String? = null
|
||||
val statusLabel: String? = null,
|
||||
val voiceModeState: VoiceModeState = VoiceModeState()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import io.picoclaw.android.core.domain.usecase.ObserveConnectionUseCase
|
|||
import io.picoclaw.android.core.domain.usecase.ObserveMessagesUseCase
|
||||
import io.picoclaw.android.core.domain.usecase.ObserveStatusUseCase
|
||||
import io.picoclaw.android.core.domain.usecase.SendMessageUseCase
|
||||
import io.picoclaw.android.feature.chat.voice.VoiceModeManager
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
|
@ -24,7 +25,8 @@ class ChatViewModel(
|
|||
private val observeStatus: ObserveStatusUseCase,
|
||||
private val loadMoreMessages: LoadMoreMessagesUseCase,
|
||||
private val connectChat: ConnectChatUseCase,
|
||||
private val disconnectChat: DisconnectChatUseCase
|
||||
private val disconnectChat: DisconnectChatUseCase,
|
||||
private val voiceModeManager: VoiceModeManager
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ChatUiState())
|
||||
|
|
@ -53,6 +55,12 @@ class ChatViewModel(
|
|||
_uiState.update { it.copy(statusLabel = label) }
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
voiceModeManager.state.collect { voiceState ->
|
||||
_uiState.update { it.copy(voiceModeState = voiceState) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onEvent(event: ChatEvent) {
|
||||
|
|
@ -91,11 +99,18 @@ class ChatViewModel(
|
|||
is ChatEvent.OnErrorDismissed -> {
|
||||
_uiState.update { it.copy(error = null) }
|
||||
}
|
||||
is ChatEvent.OnVoiceModeStart -> {
|
||||
voiceModeManager.start(viewModelScope)
|
||||
}
|
||||
is ChatEvent.OnVoiceModeStop -> {
|
||||
voiceModeManager.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
voiceModeManager.destroy()
|
||||
disconnectChat()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
package io.picoclaw.android.feature.chat
|
||||
|
||||
import io.picoclaw.android.core.domain.model.TtsConfig
|
||||
import io.picoclaw.android.core.domain.model.TtsEngineInfo
|
||||
import io.picoclaw.android.core.domain.model.TtsVoiceInfo
|
||||
|
||||
data class SettingsUiState(
|
||||
val ttsConfig: TtsConfig = TtsConfig(),
|
||||
val availableEngines: List<TtsEngineInfo> = emptyList(),
|
||||
val availableVoices: List<TtsVoiceInfo> = emptyList(),
|
||||
val isTesting: Boolean = false
|
||||
)
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package io.picoclaw.android.feature.chat
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.picoclaw.android.core.domain.repository.TtsCatalogRepository
|
||||
import io.picoclaw.android.core.domain.repository.TtsSettingsRepository
|
||||
import io.picoclaw.android.feature.chat.voice.TextToSpeechWrapper
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SettingsViewModel(
|
||||
private val ttsSettingsRepository: TtsSettingsRepository,
|
||||
private val ttsCatalogRepository: TtsCatalogRepository,
|
||||
private val ttsWrapper: TextToSpeechWrapper
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(SettingsUiState())
|
||||
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
ttsSettingsRepository.ttsConfig.collect { config ->
|
||||
_uiState.update { it.copy(ttsConfig = config) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
ttsCatalogRepository.availableEngines.collect { engines ->
|
||||
_uiState.update { it.copy(availableEngines = engines) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
ttsCatalogRepository.availableVoices.collect { voices ->
|
||||
_uiState.update { it.copy(availableVoices = voices) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onEngineSelected(packageName: String?) {
|
||||
viewModelScope.launch { ttsSettingsRepository.updateEngine(packageName) }
|
||||
}
|
||||
|
||||
fun onVoiceSelected(voiceName: String?) {
|
||||
viewModelScope.launch { ttsSettingsRepository.updateVoiceName(voiceName) }
|
||||
}
|
||||
|
||||
fun onSpeechRateChanged(rate: Float) {
|
||||
viewModelScope.launch { ttsSettingsRepository.updateSpeechRate(rate) }
|
||||
}
|
||||
|
||||
fun onPitchChanged(pitch: Float) {
|
||||
viewModelScope.launch { ttsSettingsRepository.updatePitch(pitch) }
|
||||
}
|
||||
|
||||
fun onTestSpeak() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isTesting = true) }
|
||||
ttsWrapper.speak("これはテスト音声です。This is a test.")
|
||||
_uiState.update { it.copy(isTesting = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import androidx.compose.material.icons.Icons
|
|||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.CameraAlt
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
|
|
@ -28,6 +29,7 @@ fun MessageInput(
|
|||
onSendClick: () -> Unit,
|
||||
onCameraClick: () -> Unit,
|
||||
onGalleryClick: () -> Unit,
|
||||
onMicClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
|
|
@ -42,6 +44,9 @@ fun MessageInput(
|
|||
IconButton(onClick = onGalleryClick) {
|
||||
Icon(Icons.Default.Image, contentDescription = "Gallery")
|
||||
}
|
||||
IconButton(onClick = onMicClick) {
|
||||
Icon(Icons.Default.Mic, contentDescription = "Voice")
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = onTextChanged,
|
||||
|
|
|
|||
|
|
@ -3,14 +3,20 @@ package io.picoclaw.android.feature.chat.screen
|
|||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
|
|
@ -33,12 +39,14 @@ import io.picoclaw.android.feature.chat.component.ImagePreviewRow
|
|||
import io.picoclaw.android.feature.chat.component.MessageInput
|
||||
import io.picoclaw.android.feature.chat.component.MessageList
|
||||
import io.picoclaw.android.feature.chat.component.StatusIndicator
|
||||
import io.picoclaw.android.feature.chat.voice.VoiceModeOverlay
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import java.io.File
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
onNavigateToSettings: () -> Unit = {},
|
||||
viewModel: ChatViewModel = koinViewModel()
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
|
@ -88,6 +96,34 @@ fun ChatScreen(
|
|||
}
|
||||
}
|
||||
|
||||
// RECORD_AUDIO permission
|
||||
var pendingVoiceStart by remember { mutableStateOf(false) }
|
||||
|
||||
val micPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
viewModel.onEvent(ChatEvent.OnVoiceModeStart)
|
||||
}
|
||||
pendingVoiceStart = false
|
||||
}
|
||||
|
||||
val onMicClick = {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
viewModel.onEvent(ChatEvent.OnVoiceModeStart)
|
||||
} else {
|
||||
pendingVoiceStart = true
|
||||
micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
}
|
||||
|
||||
// Back handler for voice mode
|
||||
BackHandler(enabled = uiState.voiceModeState.isActive) {
|
||||
viewModel.onEvent(ChatEvent.OnVoiceModeStop)
|
||||
}
|
||||
|
||||
val shouldLoadMore by remember {
|
||||
derivedStateOf {
|
||||
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()
|
||||
|
|
@ -116,49 +152,64 @@ fun ChatScreen(
|
|||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(title = { Text("PicoClaw") })
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
ConnectionBanner(connectionState = uiState.connectionState)
|
||||
|
||||
MessageList(
|
||||
messages = uiState.messages,
|
||||
listState = listState,
|
||||
isLoadingMore = uiState.isLoadingMore,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
StatusIndicator(label = uiState.statusLabel)
|
||||
|
||||
ImagePreviewRow(
|
||||
images = uiState.pendingImages,
|
||||
onRemove = { viewModel.onEvent(ChatEvent.OnImageRemoved(it)) }
|
||||
)
|
||||
|
||||
MessageInput(
|
||||
text = uiState.inputText,
|
||||
onTextChanged = { viewModel.onEvent(ChatEvent.OnInputChanged(it)) },
|
||||
onSendClick = { viewModel.onEvent(ChatEvent.OnSendClick) },
|
||||
onCameraClick = {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
launchCamera()
|
||||
} else {
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("PicoClaw") },
|
||||
actions = {
|
||||
IconButton(onClick = onNavigateToSettings) {
|
||||
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
||||
}
|
||||
}
|
||||
},
|
||||
onGalleryClick = {
|
||||
galleryLauncher.launch("image/*")
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
ConnectionBanner(connectionState = uiState.connectionState)
|
||||
|
||||
MessageList(
|
||||
messages = uiState.messages,
|
||||
listState = listState,
|
||||
isLoadingMore = uiState.isLoadingMore,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
StatusIndicator(label = uiState.statusLabel)
|
||||
|
||||
ImagePreviewRow(
|
||||
images = uiState.pendingImages,
|
||||
onRemove = { viewModel.onEvent(ChatEvent.OnImageRemoved(it)) }
|
||||
)
|
||||
|
||||
MessageInput(
|
||||
text = uiState.inputText,
|
||||
onTextChanged = { viewModel.onEvent(ChatEvent.OnInputChanged(it)) },
|
||||
onSendClick = { viewModel.onEvent(ChatEvent.OnSendClick) },
|
||||
onCameraClick = {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
launchCamera()
|
||||
} else {
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
},
|
||||
onGalleryClick = {
|
||||
galleryLauncher.launch("image/*")
|
||||
},
|
||||
onMicClick = onMicClick
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
VoiceModeOverlay(
|
||||
state = uiState.voiceModeState,
|
||||
onClose = { viewModel.onEvent(ChatEvent.OnVoiceModeStop) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,242 @@
|
|||
// TTS settings are currently used only within the chat feature, so this screen
|
||||
// is placed under feature/chat. If TTS settings become shared across multiple
|
||||
// features in the future, consider extracting them into a dedicated feature/settings module.
|
||||
package io.picoclaw.android.feature.chat.screen
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.picoclaw.android.core.domain.model.TtsEngineInfo
|
||||
import io.picoclaw.android.core.domain.model.TtsVoiceInfo
|
||||
import io.picoclaw.android.feature.chat.SettingsViewModel
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: SettingsViewModel = koinViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Settings") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
Text("Text-to-Speech", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
EngineSelector(
|
||||
selectedEngine = uiState.ttsConfig.enginePackageName,
|
||||
engines = uiState.availableEngines,
|
||||
onEngineSelected = viewModel::onEngineSelected
|
||||
)
|
||||
|
||||
VoiceSelector(
|
||||
selectedVoiceName = uiState.ttsConfig.voiceName,
|
||||
voices = uiState.availableVoices,
|
||||
onVoiceSelected = viewModel::onVoiceSelected
|
||||
)
|
||||
|
||||
SliderSetting(
|
||||
label = "Speed",
|
||||
value = uiState.ttsConfig.speechRate,
|
||||
valueRange = 0.5f..2.0f,
|
||||
onValueChangeFinished = viewModel::onSpeechRateChanged
|
||||
)
|
||||
|
||||
SliderSetting(
|
||||
label = "Pitch",
|
||||
value = uiState.ttsConfig.pitch,
|
||||
valueRange = 0.5f..2.0f,
|
||||
onValueChangeFinished = viewModel::onPitchChanged
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = viewModel::onTestSpeak,
|
||||
enabled = !uiState.isTesting
|
||||
) {
|
||||
Text(if (uiState.isTesting) "Speaking..." else "Test Voice")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun EngineSelector(
|
||||
selectedEngine: String?,
|
||||
engines: List<TtsEngineInfo>,
|
||||
onEngineSelected: (String?) -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val displayText = if (selectedEngine == null) {
|
||||
"System Default"
|
||||
} else {
|
||||
engines.find { it.packageName == selectedEngine }?.label ?: selectedEngine
|
||||
}
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = displayText,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Engine") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
|
||||
modifier = Modifier
|
||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("System Default") },
|
||||
onClick = {
|
||||
onEngineSelected(null)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
engines.forEach { engine ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(engine.label) },
|
||||
onClick = {
|
||||
onEngineSelected(engine.packageName)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun VoiceSelector(
|
||||
selectedVoiceName: String?,
|
||||
voices: List<TtsVoiceInfo>,
|
||||
onVoiceSelected: (String?) -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val displayText = if (selectedVoiceName == null) {
|
||||
"System Default"
|
||||
} else {
|
||||
voices.find { it.name == selectedVoiceName }?.displayLabel ?: selectedVoiceName
|
||||
}
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = displayText,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Voice") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
|
||||
modifier = Modifier
|
||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("System Default") },
|
||||
onClick = {
|
||||
onVoiceSelected(null)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
voices.forEach { voice ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(voice.displayLabel) },
|
||||
onClick = {
|
||||
onVoiceSelected(voice.name)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SliderSetting(
|
||||
label: String,
|
||||
value: Float,
|
||||
valueRange: ClosedFloatingPointRange<Float>,
|
||||
onValueChangeFinished: (Float) -> Unit
|
||||
) {
|
||||
var localValue by remember(value) { mutableFloatStateOf(value) }
|
||||
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"%.1f".format(localValue),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
Slider(
|
||||
value = localValue,
|
||||
onValueChange = { localValue = it },
|
||||
onValueChangeFinished = { onValueChangeFinished(localValue) },
|
||||
valueRange = valueRange,
|
||||
steps = 14
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package io.picoclaw.android.feature.chat.voice
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.speech.RecognitionListener
|
||||
import android.speech.RecognizerIntent
|
||||
import android.speech.SpeechRecognizer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
|
||||
sealed interface SttResult {
|
||||
data class Partial(val text: String) : SttResult
|
||||
data class Final(val text: String) : SttResult
|
||||
data class Error(val code: Int) : SttResult
|
||||
data class RmsChanged(val rms: Float) : SttResult
|
||||
}
|
||||
|
||||
class SpeechRecognizerWrapper(private val context: Context) {
|
||||
|
||||
fun startListening(): Flow<SttResult> = callbackFlow {
|
||||
val recognizer = SpeechRecognizer.createSpeechRecognizer(context)
|
||||
|
||||
val listener = object : RecognitionListener {
|
||||
override fun onReadyForSpeech(params: Bundle?) {}
|
||||
override fun onBeginningOfSpeech() {}
|
||||
override fun onBufferReceived(buffer: ByteArray?) {}
|
||||
override fun onEndOfSpeech() {}
|
||||
override fun onEvent(eventType: Int, params: Bundle?) {}
|
||||
|
||||
override fun onRmsChanged(rmsdB: Float) {
|
||||
trySend(SttResult.RmsChanged(rmsdB))
|
||||
}
|
||||
|
||||
override fun onPartialResults(partialResults: Bundle?) {
|
||||
val texts = partialResults
|
||||
?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
|
||||
val text = texts?.firstOrNull() ?: return
|
||||
trySend(SttResult.Partial(text))
|
||||
}
|
||||
|
||||
override fun onResults(results: Bundle?) {
|
||||
val texts = results
|
||||
?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
|
||||
val text = texts?.firstOrNull().orEmpty()
|
||||
trySend(SttResult.Final(text))
|
||||
channel.close()
|
||||
}
|
||||
|
||||
override fun onError(error: Int) {
|
||||
trySend(SttResult.Error(error))
|
||||
channel.close()
|
||||
}
|
||||
}
|
||||
|
||||
recognizer.setRecognitionListener(listener)
|
||||
|
||||
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||
putExtra(
|
||||
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
||||
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
|
||||
)
|
||||
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
|
||||
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
|
||||
}
|
||||
recognizer.startListening(intent)
|
||||
|
||||
awaitClose {
|
||||
recognizer.cancel()
|
||||
recognizer.destroy()
|
||||
}
|
||||
}.flowOn(Dispatchers.Main)
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
package io.picoclaw.android.feature.chat.voice
|
||||
|
||||
import android.content.Context
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.speech.tts.UtteranceProgressListener
|
||||
import io.picoclaw.android.core.domain.model.TtsConfig
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.UUID
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class TextToSpeechWrapper(
|
||||
private val context: Context,
|
||||
ttsConfigFlow: Flow<TtsConfig>
|
||||
) {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private val mutex = Mutex()
|
||||
|
||||
private var tts: TextToSpeech? = null
|
||||
private var initialized = false
|
||||
private var currentConfig = TtsConfig()
|
||||
private var currentEnginePackage: String? = null
|
||||
|
||||
init {
|
||||
initTtsEngine(null)
|
||||
|
||||
scope.launch {
|
||||
ttsConfigFlow.collect { config ->
|
||||
mutex.withLock { currentConfig = config }
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
ttsConfigFlow
|
||||
.distinctUntilChangedBy { it.enginePackageName }
|
||||
.collect { config ->
|
||||
mutex.withLock {
|
||||
if (config.enginePackageName != currentEnginePackage) {
|
||||
switchEngine(config.enginePackageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initTtsEngine(enginePackageName: String?) {
|
||||
tts?.stop()
|
||||
tts?.shutdown()
|
||||
initialized = false
|
||||
currentEnginePackage = enginePackageName
|
||||
|
||||
val listener = TextToSpeech.OnInitListener { status ->
|
||||
if (status == TextToSpeech.SUCCESS) {
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
tts = if (enginePackageName != null) {
|
||||
TextToSpeech(context, listener, enginePackageName)
|
||||
} else {
|
||||
TextToSpeech(context, listener)
|
||||
}
|
||||
}
|
||||
|
||||
private fun switchEngine(enginePackageName: String?) {
|
||||
initTtsEngine(enginePackageName)
|
||||
}
|
||||
|
||||
private fun applyConfig(engine: TextToSpeech) {
|
||||
engine.setSpeechRate(currentConfig.speechRate)
|
||||
engine.setPitch(currentConfig.pitch)
|
||||
currentConfig.voiceName?.let { name ->
|
||||
engine.voices?.firstOrNull { it.name == name }?.let { engine.voice = it }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun speak(text: String): Boolean = suspendCancellableCoroutine { cont ->
|
||||
val engine = tts
|
||||
if (engine == null || !initialized) {
|
||||
cont.resume(false)
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
|
||||
applyConfig(engine)
|
||||
|
||||
val utteranceId = UUID.randomUUID().toString()
|
||||
|
||||
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||
override fun onStart(id: String?) {}
|
||||
|
||||
override fun onDone(id: String?) {
|
||||
if (id == utteranceId && cont.isActive) {
|
||||
cont.resume(true)
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onError(id: String?) {
|
||||
if (id == utteranceId && cont.isActive) {
|
||||
cont.resume(false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(id: String?, errorCode: Int) {
|
||||
if (id == utteranceId && cont.isActive) {
|
||||
cont.resume(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
cont.invokeOnCancellation {
|
||||
engine.stop()
|
||||
}
|
||||
|
||||
engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, utteranceId)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
tts?.stop()
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
scope.cancel()
|
||||
tts?.stop()
|
||||
tts?.shutdown()
|
||||
tts = null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
package io.picoclaw.android.feature.chat.voice
|
||||
|
||||
import android.speech.SpeechRecognizer
|
||||
import io.picoclaw.android.core.domain.model.MessageSender
|
||||
import io.picoclaw.android.core.domain.model.MessageStatus
|
||||
import io.picoclaw.android.core.domain.model.VoicePhase
|
||||
import io.picoclaw.android.core.domain.usecase.ObserveMessagesUseCase
|
||||
import io.picoclaw.android.core.domain.usecase.ObserveStatusUseCase
|
||||
import io.picoclaw.android.core.domain.usecase.SendMessageUseCase
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.selects.onTimeout
|
||||
import kotlinx.coroutines.selects.select
|
||||
|
||||
class VoiceModeManager(
|
||||
private val sttWrapper: SpeechRecognizerWrapper,
|
||||
private val ttsWrapper: TextToSpeechWrapper,
|
||||
private val sendMessage: SendMessageUseCase,
|
||||
private val observeMessages: ObserveMessagesUseCase,
|
||||
private val observeStatus: ObserveStatusUseCase
|
||||
) {
|
||||
|
||||
private val _state = MutableStateFlow(VoiceModeState())
|
||||
val state: StateFlow<VoiceModeState> = _state.asStateFlow()
|
||||
|
||||
private var loopJob: Job? = null
|
||||
|
||||
fun start(scope: CoroutineScope) {
|
||||
if (loopJob?.isActive == true) return
|
||||
_state.update {
|
||||
VoiceModeState(isActive = true, phase = VoicePhase.LISTENING)
|
||||
}
|
||||
loopJob = scope.launch {
|
||||
voiceLoop()
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
loopJob?.cancel()
|
||||
loopJob = null
|
||||
ttsWrapper.stop()
|
||||
_state.value = VoiceModeState()
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
stop()
|
||||
}
|
||||
|
||||
// Fix #5: coroutineScope {} で自身のスコープを生成(CoroutineScope. 拡張関数を廃止)
|
||||
private suspend fun voiceLoop() = coroutineScope {
|
||||
val spokenIds = mutableSetOf<String>()
|
||||
val speechQueue = Channel<String>(Channel.UNLIMITED)
|
||||
|
||||
// Fix #3: knownMessageIds のスナップショットを collectorJob 内に移動し、
|
||||
// スナップショットと collect 開始の間のレースウィンドウを排除
|
||||
val collectorJob = launch {
|
||||
val initialIds = observeMessages().value.map { it.id }.toSet()
|
||||
observeMessages().collect { messages ->
|
||||
messages.forEach { msg ->
|
||||
if (msg.sender == MessageSender.AGENT &&
|
||||
msg.id !in initialIds &&
|
||||
msg.id !in spokenIds &&
|
||||
msg.status == MessageStatus.RECEIVED
|
||||
) {
|
||||
spokenIds.add(msg.id)
|
||||
speechQueue.send(msg.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
while (isActive) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
phase = VoicePhase.LISTENING,
|
||||
recognizedText = "", responseText = "",
|
||||
statusText = null,
|
||||
errorMessage = null, amplitudeNormalized = 0f
|
||||
)
|
||||
}
|
||||
|
||||
val userTextChannel = Channel<String?>(1)
|
||||
val listenJob = launch {
|
||||
val text = listen()
|
||||
userTextChannel.send(text)
|
||||
}
|
||||
|
||||
select<Unit> {
|
||||
userTextChannel.onReceive { text ->
|
||||
if (!text.isNullOrBlank()) {
|
||||
_state.update { it.copy(phase = VoicePhase.SENDING, recognizedText = text) }
|
||||
try {
|
||||
sendMessage(text)
|
||||
} catch (e: Exception) {
|
||||
_state.update {
|
||||
it.copy(phase = VoicePhase.ERROR, errorMessage = "送信に失敗しました")
|
||||
}
|
||||
delay(2000)
|
||||
return@onReceive
|
||||
}
|
||||
awaitAndSpeakResponse(speechQueue)
|
||||
} else {
|
||||
drainSpeechQueue(speechQueue)
|
||||
}
|
||||
}
|
||||
speechQueue.onReceive { content ->
|
||||
listenJob.cancel()
|
||||
speakAndDrain(content, speechQueue)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
collectorJob.cancel()
|
||||
speechQueue.close()
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface WaitResult {
|
||||
data class Message(val content: String) : WaitResult
|
||||
data object Heartbeat : WaitResult
|
||||
data object Timeout : WaitResult
|
||||
}
|
||||
|
||||
// Fix #1 + #2 + #4 + #5: select で speechQueue / heartbeat / onTimeout(30s) を待つ
|
||||
private suspend fun awaitAndSpeakResponse(speechQueue: Channel<String>) = coroutineScope {
|
||||
val heartbeat = Channel<Unit>(Channel.CONFLATED)
|
||||
|
||||
val statusJob = launch {
|
||||
observeStatus().collect { label ->
|
||||
_state.update { it.copy(statusText = label) } // Fix #4: null も伝播
|
||||
if (label != null) heartbeat.trySend(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
_state.update { it.copy(phase = VoicePhase.THINKING, statusText = null) }
|
||||
|
||||
while (true) {
|
||||
val result = select<WaitResult> {
|
||||
speechQueue.onReceive { WaitResult.Message(it) }
|
||||
heartbeat.onReceive { WaitResult.Heartbeat }
|
||||
onTimeout(30_000) { WaitResult.Timeout }
|
||||
}
|
||||
|
||||
when (result) {
|
||||
WaitResult.Timeout -> {
|
||||
_state.update {
|
||||
it.copy(phase = VoicePhase.ERROR, errorMessage = "応答がタイムアウトしました")
|
||||
}
|
||||
delay(2000)
|
||||
return@coroutineScope
|
||||
}
|
||||
WaitResult.Heartbeat -> continue
|
||||
is WaitResult.Message -> {
|
||||
speakAndDrain(result.content, speechQueue)
|
||||
val currentStatus = observeStatus().value
|
||||
if (currentStatus == null) return@coroutineScope
|
||||
_state.update {
|
||||
it.copy(phase = VoicePhase.THINKING, statusText = currentStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
statusJob.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun speakAndDrain(firstContent: String, speechQueue: Channel<String>) {
|
||||
_state.update { it.copy(phase = VoicePhase.SPEAKING, responseText = firstContent) }
|
||||
ttsWrapper.speak(firstContent)
|
||||
while (true) {
|
||||
val next = speechQueue.tryReceive().getOrNull() ?: break
|
||||
_state.update { it.copy(responseText = next) }
|
||||
ttsWrapper.speak(next)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun drainSpeechQueue(speechQueue: Channel<String>) {
|
||||
val first = speechQueue.tryReceive().getOrNull() ?: return
|
||||
speakAndDrain(first, speechQueue)
|
||||
}
|
||||
|
||||
private suspend fun listen(): String? {
|
||||
var finalText: String? = null
|
||||
sttWrapper.startListening().collect { result ->
|
||||
when (result) {
|
||||
is SttResult.Partial -> {
|
||||
_state.update { it.copy(recognizedText = result.text) }
|
||||
}
|
||||
is SttResult.Final -> {
|
||||
finalText = result.text
|
||||
}
|
||||
is SttResult.RmsChanged -> {
|
||||
val normalized = ((result.rms + 2f) / 12f).coerceIn(0f, 1f)
|
||||
_state.update { it.copy(amplitudeNormalized = normalized) }
|
||||
}
|
||||
is SttResult.Error -> {
|
||||
if (result.code == SpeechRecognizer.ERROR_NO_MATCH ||
|
||||
result.code == SpeechRecognizer.ERROR_SPEECH_TIMEOUT
|
||||
) {
|
||||
finalText = ""
|
||||
} else {
|
||||
_state.update {
|
||||
it.copy(
|
||||
phase = VoicePhase.ERROR,
|
||||
errorMessage = "音声認識エラー (code=${result.code})"
|
||||
)
|
||||
}
|
||||
delay(2000)
|
||||
finalText = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return finalText
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
package io.picoclaw.android.feature.chat.voice
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.picoclaw.android.core.domain.model.VoicePhase
|
||||
|
||||
@Composable
|
||||
fun VoiceModeOverlay(
|
||||
state: VoiceModeState,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = state.isActive,
|
||||
enter = fadeIn() + slideInVertically { it / 2 },
|
||||
exit = fadeOut() + slideOutVertically { it / 2 }
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
// Close button
|
||||
IconButton(
|
||||
onClick = onClose,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "閉じる",
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Center content
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
VoiceOrb(
|
||||
phase = state.phase,
|
||||
amplitudeNormalized = state.amplitudeNormalized
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Phase label
|
||||
Text(
|
||||
text = state.statusText.takeIf { state.phase == VoicePhase.THINKING }
|
||||
?: phaseLabel(state.phase),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Recognized text
|
||||
if (state.recognizedText.isNotEmpty()) {
|
||||
Text(
|
||||
text = state.recognizedText,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
// Response text
|
||||
if (state.responseText.isNotEmpty() && state.phase == VoicePhase.SPEAKING) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = state.responseText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
// Error message
|
||||
if (state.errorMessage != null) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = state.errorMessage,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun phaseLabel(phase: VoicePhase): String = when (phase) {
|
||||
VoicePhase.IDLE -> ""
|
||||
VoicePhase.LISTENING -> "聞き取り中..."
|
||||
VoicePhase.SENDING -> "送信中..."
|
||||
VoicePhase.THINKING -> "考え中..."
|
||||
VoicePhase.SPEAKING -> "話しています..."
|
||||
VoicePhase.ERROR -> "エラー"
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package io.picoclaw.android.feature.chat.voice
|
||||
|
||||
import io.picoclaw.android.core.domain.model.VoicePhase
|
||||
|
||||
data class VoiceModeState(
|
||||
val isActive: Boolean = false,
|
||||
val phase: VoicePhase = VoicePhase.IDLE,
|
||||
val recognizedText: String = "",
|
||||
val responseText: String = "",
|
||||
val statusText: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
val amplitudeNormalized: Float = 0f
|
||||
)
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
package io.picoclaw.android.feature.chat.voice
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.picoclaw.android.core.domain.model.VoicePhase
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
private val ListeningColor = Color(0xFF4A9EFF)
|
||||
private val SendingColor = Color(0xFFFF8C42)
|
||||
private val ThinkingColor = Color(0xFFA855F7)
|
||||
private val SpeakingColor = Color(0xFF22C55E)
|
||||
private val ErrorColor = Color(0xFFEF4444)
|
||||
private val IdleColor = Color(0xFF6B7280)
|
||||
|
||||
@Composable
|
||||
fun VoiceOrb(
|
||||
phase: VoicePhase,
|
||||
amplitudeNormalized: Float,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val transition = rememberInfiniteTransition(label = "orb")
|
||||
|
||||
val pulse by transition.animateFloat(
|
||||
initialValue = 0.95f,
|
||||
targetValue = 1.05f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(
|
||||
durationMillis = when (phase) {
|
||||
VoicePhase.THINKING -> 800
|
||||
VoicePhase.SPEAKING -> 600
|
||||
else -> 1200
|
||||
},
|
||||
easing = LinearEasing
|
||||
),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "pulse"
|
||||
)
|
||||
|
||||
val rotation by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 360f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 4000, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart
|
||||
),
|
||||
label = "rotation"
|
||||
)
|
||||
|
||||
val glowAlpha by transition.animateFloat(
|
||||
initialValue = 0.3f,
|
||||
targetValue = 0.6f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1500, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "glow"
|
||||
)
|
||||
|
||||
val primaryColor = when (phase) {
|
||||
VoicePhase.LISTENING -> ListeningColor
|
||||
VoicePhase.SENDING -> SendingColor
|
||||
VoicePhase.THINKING -> ThinkingColor
|
||||
VoicePhase.SPEAKING -> SpeakingColor
|
||||
VoicePhase.ERROR -> ErrorColor
|
||||
VoicePhase.IDLE -> IdleColor
|
||||
}
|
||||
|
||||
Canvas(modifier = modifier.size(200.dp)) {
|
||||
val center = Offset(size.width / 2f, size.height / 2f)
|
||||
val baseRadius = size.minDimension / 4f
|
||||
|
||||
val scaleMultiplier = if (phase == VoicePhase.LISTENING) {
|
||||
1f + amplitudeNormalized * 0.3f
|
||||
} else {
|
||||
pulse
|
||||
}
|
||||
|
||||
val orbRadius = baseRadius * scaleMultiplier
|
||||
|
||||
// Glow
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(
|
||||
primaryColor.copy(alpha = glowAlpha),
|
||||
primaryColor.copy(alpha = 0f)
|
||||
),
|
||||
center = center,
|
||||
radius = orbRadius * 2f
|
||||
),
|
||||
radius = orbRadius * 2f,
|
||||
center = center
|
||||
)
|
||||
|
||||
// Main orb
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(
|
||||
primaryColor,
|
||||
primaryColor.copy(alpha = 0.7f)
|
||||
),
|
||||
center = Offset(
|
||||
center.x - orbRadius * 0.2f,
|
||||
center.y - orbRadius * 0.2f
|
||||
),
|
||||
radius = orbRadius * 1.5f
|
||||
),
|
||||
radius = orbRadius,
|
||||
center = center
|
||||
)
|
||||
|
||||
// Wave rings
|
||||
drawWaveRings(center, orbRadius, primaryColor, rotation, phase, amplitudeNormalized)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawWaveRings(
|
||||
center: Offset,
|
||||
orbRadius: Float,
|
||||
color: Color,
|
||||
rotation: Float,
|
||||
phase: VoicePhase,
|
||||
amplitude: Float
|
||||
) {
|
||||
val ringCount = 3
|
||||
for (i in 1..ringCount) {
|
||||
val ringRadius = orbRadius * (1.2f + i * 0.25f)
|
||||
val alpha = (0.4f - i * 0.1f).coerceAtLeast(0.05f)
|
||||
|
||||
val waveAmplitude = when (phase) {
|
||||
VoicePhase.LISTENING -> amplitude * 8f
|
||||
VoicePhase.SPEAKING -> 4f
|
||||
VoicePhase.THINKING -> 2f
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
if (waveAmplitude > 0f) {
|
||||
val path = androidx.compose.ui.graphics.Path()
|
||||
val steps = 72
|
||||
for (step in 0..steps) {
|
||||
val angle = (step.toFloat() / steps) * 2f * PI.toFloat()
|
||||
val wave = sin(angle * 6f + Math.toRadians(rotation.toDouble()).toFloat() * (i + 1)) * waveAmplitude
|
||||
val r = ringRadius + wave
|
||||
val x = center.x + cos(angle) * r
|
||||
val y = center.y + sin(angle) * r
|
||||
if (step == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
||||
}
|
||||
path.close()
|
||||
drawPath(
|
||||
path = path,
|
||||
color = color.copy(alpha = alpha),
|
||||
style = Stroke(width = 2f)
|
||||
)
|
||||
} else {
|
||||
drawCircle(
|
||||
color = color.copy(alpha = alpha),
|
||||
radius = ringRadius,
|
||||
center = center,
|
||||
style = Stroke(width = 1.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,8 @@ core-ktx = "1.17.0"
|
|||
coroutines = "1.10.2"
|
||||
coil = "3.1.0"
|
||||
markdown-renderer = "0.39.0"
|
||||
navigation = "2.9.0"
|
||||
datastore = "1.1.7"
|
||||
|
||||
[libraries]
|
||||
# Compose
|
||||
|
|
@ -53,6 +55,12 @@ coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutin
|
|||
# Coil
|
||||
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
||||
|
||||
# Navigation
|
||||
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
|
||||
|
||||
# DataStore
|
||||
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||
|
||||
# Markdown
|
||||
markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdown-renderer" }
|
||||
|
||||
|
|
|
|||
|
|
@ -439,8 +439,36 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
|
|||
})
|
||||
}
|
||||
|
||||
// Start heartbeat goroutine - resend current status every 10s
|
||||
var currentStatus atomic.Value
|
||||
currentStatus.Store("思考中...")
|
||||
heartbeatCtx, heartbeatCancel := context.WithCancel(ctx)
|
||||
defer heartbeatCancel()
|
||||
|
||||
if !constants.IsInternalChannel(opts.Channel) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-heartbeatCtx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if status, ok := currentStatus.Load().(string); ok && status != "" {
|
||||
al.bus.PublishOutbound(bus.OutboundMessage{
|
||||
Channel: opts.Channel,
|
||||
ChatID: opts.ChatID,
|
||||
Content: status,
|
||||
Type: "status",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 5. Run LLM iteration loop
|
||||
finalContent, iteration, err := al.runLLMIteration(ctx, messages, opts)
|
||||
finalContent, iteration, err := al.runLLMIteration(ctx, messages, opts, ¤tStatus)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
@ -485,7 +513,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
|
|||
|
||||
// runLLMIteration executes the LLM call loop with tool handling.
|
||||
// Returns the final content, iteration count, and any error.
|
||||
func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.Message, opts processOptions) (string, int, error) {
|
||||
func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.Message, opts processOptions, currentStatus *atomic.Value) (string, int, error) {
|
||||
iteration := 0
|
||||
var finalContent string
|
||||
|
||||
|
|
@ -747,6 +775,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
|
|||
// Emit tool use status indicator
|
||||
if !constants.IsInternalChannel(opts.Channel) {
|
||||
if label := statusLabel(tc.Name, tc.Arguments); label != "" {
|
||||
currentStatus.Store(label)
|
||||
al.bus.PublishOutbound(bus.OutboundMessage{
|
||||
Channel: opts.Channel,
|
||||
ChatID: opts.ChatID,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue