新增: 添加 ConversationApiPort 和 ConversationRealtimePort
为对话流程定义按能力划分的端口,并提供委托给 ApiService 和 WebSocketClient 的适配器。
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
package com.huaga.life_echo.feature.conversation.adapters
|
||||
|
||||
import com.huaga.life_echo.feature.conversation.ports.ConversationApiPort
|
||||
import com.huaga.life_echo.network.ApiService
|
||||
import com.huaga.life_echo.network.models.ChapterDto
|
||||
import com.huaga.life_echo.network.models.CreateConversationResponse
|
||||
import com.huaga.life_echo.network.models.MessageDto
|
||||
import com.huaga.life_echo.network.models.TasksStatusDto
|
||||
|
||||
class ConversationApiAdapter(
|
||||
private val apiService: ApiService,
|
||||
) : ConversationApiPort {
|
||||
|
||||
override suspend fun createConversation(): Result<CreateConversationResponse> {
|
||||
return apiService.createConversation()
|
||||
}
|
||||
|
||||
override suspend fun getMessages(conversationId: String): Result<List<MessageDto>> {
|
||||
return apiService.getMessages(conversationId)
|
||||
}
|
||||
|
||||
override suspend fun getTasksStatus(): Result<TasksStatusDto> {
|
||||
return apiService.getTasksStatus()
|
||||
}
|
||||
|
||||
override suspend fun getChapters(): Result<List<ChapterDto>> {
|
||||
return apiService.getChapters()
|
||||
}
|
||||
|
||||
override suspend fun clearTasks(): Result<Unit> {
|
||||
return apiService.clearTasks()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.huaga.life_echo.feature.conversation.adapters
|
||||
|
||||
import com.huaga.life_echo.feature.conversation.ports.AudioSegmentRequest
|
||||
import com.huaga.life_echo.feature.conversation.ports.ConversationRealtimePort
|
||||
import com.huaga.life_echo.network.WebSocketClient
|
||||
import com.huaga.life_echo.network.WebSocketMessage
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class ConversationRealtimeAdapter(
|
||||
private val webSocketClient: WebSocketClient,
|
||||
) : ConversationRealtimePort {
|
||||
|
||||
private val _state = MutableStateFlow<ConversationRealtimePort.State>(
|
||||
ConversationRealtimePort.State.NotConnected
|
||||
)
|
||||
override val state: StateFlow<ConversationRealtimePort.State> = _state.asStateFlow()
|
||||
|
||||
override suspend fun prepare() {
|
||||
_state.value = ConversationRealtimePort.State.Preparing
|
||||
}
|
||||
|
||||
override suspend fun connect(
|
||||
conversationId: String,
|
||||
token: String?,
|
||||
onMessage: (WebSocketMessage) -> Unit,
|
||||
onError: ((String) -> Unit)?,
|
||||
) {
|
||||
webSocketClient.connect(
|
||||
conversationId = conversationId,
|
||||
token = token,
|
||||
onMessage = { message ->
|
||||
if (message.type == com.huaga.life_echo.network.MessageType.connect) {
|
||||
_state.value = ConversationRealtimePort.State.Connected(conversationId)
|
||||
}
|
||||
onMessage(message)
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
_state.value = ConversationRealtimePort.State.NotConnected
|
||||
onError?.invoke(errorMsg)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun disconnect() {
|
||||
webSocketClient.disconnect()
|
||||
_state.value = ConversationRealtimePort.State.NotConnected
|
||||
}
|
||||
|
||||
override fun isConnected(): Boolean = webSocketClient.isConnected()
|
||||
|
||||
override suspend fun sendText(conversationId: String, text: String) {
|
||||
webSocketClient.sendTextMessage(text, conversationId)
|
||||
}
|
||||
|
||||
override suspend fun sendAudioChunk(chunk: ByteArray, conversationId: String) {
|
||||
webSocketClient.sendAudioChunk(chunk, conversationId)
|
||||
}
|
||||
|
||||
override suspend fun sendAudioSegment(request: AudioSegmentRequest) {
|
||||
webSocketClient.sendAudioSegment(
|
||||
audioBytes = request.audioBytes,
|
||||
conversationId = request.conversationId,
|
||||
voiceSessionId = request.voiceSessionId,
|
||||
segmentIndex = request.segmentIndex,
|
||||
duration = request.duration,
|
||||
isLast = request.isLast,
|
||||
clientSegmentId = request.clientSegmentId,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun sendAudioMessage(audioBytes: ByteArray, conversationId: String, duration: Int) {
|
||||
webSocketClient.sendAudioMessage(audioBytes, conversationId, duration)
|
||||
}
|
||||
|
||||
override suspend fun sendTranscribeOnly(audioBytes: ByteArray, conversationId: String) {
|
||||
webSocketClient.sendTranscribeOnly(audioBytes, conversationId)
|
||||
}
|
||||
|
||||
override suspend fun sendEndConversation(conversationId: String) {
|
||||
webSocketClient.sendEndConversation(conversationId)
|
||||
}
|
||||
|
||||
override suspend fun cancelGeneration(conversationId: String) {
|
||||
webSocketClient.cancelGeneration(conversationId)
|
||||
}
|
||||
|
||||
override fun isGenerating(): Boolean = webSocketClient.isGenerating()
|
||||
|
||||
override fun setGenerating(generating: Boolean) {
|
||||
webSocketClient.setGenerating(generating)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.huaga.life_echo.feature.conversation.ports
|
||||
|
||||
import com.huaga.life_echo.network.models.ChapterDto
|
||||
import com.huaga.life_echo.network.models.CreateConversationResponse
|
||||
import com.huaga.life_echo.network.models.MessageDto
|
||||
import com.huaga.life_echo.network.models.TasksStatusDto
|
||||
|
||||
interface ConversationApiPort {
|
||||
suspend fun createConversation(): Result<CreateConversationResponse>
|
||||
suspend fun getMessages(conversationId: String): Result<List<MessageDto>>
|
||||
suspend fun getTasksStatus(): Result<TasksStatusDto>
|
||||
suspend fun getChapters(): Result<List<ChapterDto>>
|
||||
suspend fun clearTasks(): Result<Unit>
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.huaga.life_echo.feature.conversation.ports
|
||||
|
||||
import com.huaga.life_echo.network.WebSocketMessage
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface ConversationRealtimePort {
|
||||
val state: StateFlow<State>
|
||||
|
||||
suspend fun prepare()
|
||||
suspend fun connect(
|
||||
conversationId: String,
|
||||
token: String?,
|
||||
onMessage: (WebSocketMessage) -> Unit,
|
||||
onError: ((String) -> Unit)? = null,
|
||||
)
|
||||
suspend fun disconnect()
|
||||
fun isConnected(): Boolean
|
||||
|
||||
suspend fun sendText(conversationId: String, text: String)
|
||||
suspend fun sendAudioChunk(chunk: ByteArray, conversationId: String)
|
||||
suspend fun sendAudioSegment(request: AudioSegmentRequest)
|
||||
suspend fun sendAudioMessage(audioBytes: ByteArray, conversationId: String, duration: Int)
|
||||
suspend fun sendTranscribeOnly(audioBytes: ByteArray, conversationId: String)
|
||||
suspend fun sendEndConversation(conversationId: String)
|
||||
suspend fun cancelGeneration(conversationId: String)
|
||||
|
||||
fun isGenerating(): Boolean
|
||||
fun setGenerating(generating: Boolean)
|
||||
|
||||
sealed class State {
|
||||
data object NotConnected : State()
|
||||
data object Preparing : State()
|
||||
data class Connected(val conversationId: String) : State()
|
||||
}
|
||||
}
|
||||
|
||||
data class AudioSegmentRequest(
|
||||
val audioBytes: ByteArray,
|
||||
val conversationId: String,
|
||||
val voiceSessionId: String,
|
||||
val segmentIndex: Int,
|
||||
val duration: Int,
|
||||
val isLast: Boolean,
|
||||
val clientSegmentId: String? = null,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is AudioSegmentRequest) return false
|
||||
return conversationId == other.conversationId
|
||||
&& voiceSessionId == other.voiceSessionId
|
||||
&& segmentIndex == other.segmentIndex
|
||||
&& duration == other.duration
|
||||
&& isLast == other.isLast
|
||||
&& clientSegmentId == other.clientSegmentId
|
||||
&& audioBytes.contentEquals(other.audioBytes)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = audioBytes.contentHashCode()
|
||||
result = 31 * result + conversationId.hashCode()
|
||||
result = 31 * result + voiceSessionId.hashCode()
|
||||
result = 31 * result + segmentIndex
|
||||
result = 31 * result + duration
|
||||
result = 31 * result + isLast.hashCode()
|
||||
result = 31 * result + (clientSegmentId?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.huaga.life_echo.feature.conversation.adapters
|
||||
|
||||
import com.huaga.life_echo.feature.conversation.ports.ConversationRealtimePort
|
||||
import com.huaga.life_echo.network.MessageType
|
||||
import com.huaga.life_echo.network.WebSocketClient
|
||||
import com.huaga.life_echo.network.WebSocketMessage
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.mockito.Mockito
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ConversationRealtimeAdapterTest {
|
||||
|
||||
@Test
|
||||
fun connect_reports_realtime_connected_only_after_session_connects() = runTest {
|
||||
val fakeSocketClient = FakeWebSocketClient()
|
||||
val adapter = ConversationRealtimeAdapter(fakeSocketClient)
|
||||
|
||||
assertEquals(ConversationRealtimePort.State.NotConnected, adapter.state.value)
|
||||
|
||||
adapter.connect("conversation-1", "token", onMessage = {}, onError = {})
|
||||
|
||||
fakeSocketClient.simulateServerConnect()
|
||||
|
||||
assertEquals(
|
||||
ConversationRealtimePort.State.Connected("conversation-1"),
|
||||
adapter.state.value
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prepare_moves_state_to_preparing() = runTest {
|
||||
val fakeSocketClient = FakeWebSocketClient()
|
||||
val adapter = ConversationRealtimeAdapter(fakeSocketClient)
|
||||
|
||||
adapter.prepare()
|
||||
|
||||
assertEquals(ConversationRealtimePort.State.Preparing, adapter.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disconnect_resets_state_to_not_connected() = runTest {
|
||||
val fakeSocketClient = FakeWebSocketClient()
|
||||
val adapter = ConversationRealtimeAdapter(fakeSocketClient)
|
||||
|
||||
adapter.connect("conversation-1", "token", onMessage = {}, onError = {})
|
||||
fakeSocketClient.simulateServerConnect()
|
||||
|
||||
adapter.disconnect()
|
||||
|
||||
assertEquals(ConversationRealtimePort.State.NotConnected, adapter.state.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal fake that captures callbacks so the test can simulate server messages.
|
||||
* Extends [WebSocketClient] using the no-arg legacy constructor.
|
||||
*/
|
||||
private class FakeWebSocketClient : WebSocketClient() {
|
||||
private var capturedOnMessage: ((WebSocketMessage) -> Unit)? = null
|
||||
private var capturedOnError: ((String) -> Unit)? = null
|
||||
private var connected = false
|
||||
|
||||
override suspend fun connect(
|
||||
conversationId: String,
|
||||
token: String?,
|
||||
onMessage: (WebSocketMessage) -> Unit,
|
||||
onError: ((String) -> Unit)?
|
||||
) {
|
||||
capturedOnMessage = onMessage
|
||||
capturedOnError = onError
|
||||
}
|
||||
|
||||
override suspend fun disconnect() {
|
||||
connected = false
|
||||
}
|
||||
|
||||
override fun isConnected(): Boolean = connected
|
||||
|
||||
fun simulateServerConnect() {
|
||||
connected = true
|
||||
capturedOnMessage?.invoke(
|
||||
WebSocketMessage(
|
||||
type = MessageType.connect,
|
||||
conversation_id = "conversation-1",
|
||||
data = buildJsonObject { }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user