新增: 添加 ConversationApiPort 和 ConversationRealtimePort

为对话流程定义按能力划分的端口,并提供委托给 ApiService 和 WebSocketClient 的适配器。
This commit is contained in:
Kevin
2026-03-12 10:35:58 +08:00
parent e3f370a22f
commit 7e59c65602
5 changed files with 302 additions and 0 deletions

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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>
}

View File

@@ -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
}
}

View File

@@ -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 { }
)
)
}
}
}