Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.kikidan.data_remote.datasource

import com.kikidan.data.datasource.RemoteChatDataSource
import com.kikidan.data_remote.dto.CommonResponse
import com.kikidan.data_remote.dto.chat.ChatEntryResponse
import com.kikidan.data_remote.dto.chat.ConversationDetailResponse
import com.kikidan.data_remote.dto.chat.ConversationListResponse
import com.kikidan.data_remote.dto.chat.SendChatMessageRequest
import com.kikidan.data_remote.dto.chat.toChatStreamEventOrNull
import com.kikidan.data_remote.dto.chat.toDomain
import com.kikidan.data_remote.sse.serverSentEvents
import com.kikidan.data_remote.util.bodyNotNull
import com.kikidan.domain.model.chat.ChatEntry
import com.kikidan.domain.model.chat.ChatStreamEvent
import com.kikidan.domain.model.chat.Conversation
import com.kikidan.domain.model.chat.ConversationSummary
import dagger.Lazy
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.setBody
import io.ktor.http.HttpMethod
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.mapNotNull
import javax.inject.Inject

class RemoteChatDataSourceImpl
@Inject
constructor(
private val client: Lazy<HttpClient>,
) : RemoteChatDataSource {
override suspend fun getChatEntry(): ChatEntry =
client
.get()
.get(CHAT_ENTRY_URL)
.bodyNotNull<ChatEntryResponse>()
.toDomain()

override fun postChatMessage(
conversationId: String?,
content: String,
): Flow<ChatStreamEvent> =
client
.get()
.serverSentEvents(CHAT_MESSAGES_URL) {
method = HttpMethod.Post
setBody(SendChatMessageRequest(conversationId, content))
}.mapNotNull { it.toChatStreamEventOrNull() }

override suspend fun getConversations(): List<ConversationSummary> =
client
.get()
.get(CONVERSATIONS_URL)
.bodyNotNull<ConversationListResponse>()
.toDomain()

override suspend fun getConversation(conversationId: String): Conversation =
client
.get()
.get(conversationUrl(conversationId))
.bodyNotNull<ConversationDetailResponse>()
.toDomain()

override suspend fun deleteConversation(conversationId: String) {
client
.get()
.delete(conversationUrl(conversationId))
.body<CommonResponse<Unit>>()
}

companion object {
private const val CHAT_ENTRY_URL = "api/v1/chat/entry"
private const val CHAT_MESSAGES_URL = "api/v1/chat/messages"
private const val CONVERSATIONS_URL = "api/v1/chat/conversations"

private fun conversationUrl(conversationId: String): String = "$CONVERSATIONS_URL/$conversationId"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package com.kikidan.data_remote.di

import com.kikidan.data.auth.AuthTokenCacheInvalidator
import com.kikidan.data.datasource.RemoteAuthDataSource
import com.kikidan.data.datasource.RemoteChatDataSource
import com.kikidan.data_remote.auth.BearerTokenCacheInvalidator
import com.kikidan.data_remote.datasource.RemoteAuthDataSourceImpl
import com.kikidan.data_remote.datasource.RemoteChatDataSourceImpl
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
Expand All @@ -20,4 +22,8 @@ abstract class RemoteDataSourceModule {
@Binds
@Singleton
abstract fun bindAuthTokenCacheInvalidator(impl: BearerTokenCacheInvalidator): AuthTokenCacheInvalidator

@Binds
@Singleton
abstract fun bindChatRemoteDataSource(impl: RemoteChatDataSourceImpl): RemoteChatDataSource
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package com.kikidan.data_remote.dto.chat

import com.kikidan.domain.model.chat.ChatCategory
import com.kikidan.domain.model.chat.ChatEntry
import com.kikidan.domain.model.chat.ChatQuota
import com.kikidan.domain.model.chat.ChatSuggestion
import kotlinx.serialization.Serializable

@Serializable
Expand All @@ -14,11 +18,28 @@ data class ChatSuggestionResponse(
val emoji: String,
val label: String,
val seedPrompt: String,
val category: String,
val category: String?,
)

@Serializable
data class ChatQuotaResponse(
val used: Int,
val limit: Int,
)

internal fun ChatEntryResponse.toDomain(): ChatEntry =
ChatEntry(
greeting = greeting,
suggestions = suggestions.map { it.toDomain() },
quota = quota.toDomain(),
)

internal fun ChatSuggestionResponse.toDomain(): ChatSuggestion =
ChatSuggestion(
emoji = emoji,
label = label,
seedPrompt = seedPrompt,
category = if (category == null) null else ChatCategory.valueOf(category),
)

internal fun ChatQuotaResponse.toDomain(): ChatQuota = ChatQuota(used = used, limit = limit)
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
package com.kikidan.data_remote.dto.chat

import com.kikidan.data_remote.util.toInstantOrThrow
import com.kikidan.domain.model.chat.ChatAction
import com.kikidan.domain.model.chat.ChatActionType
import com.kikidan.domain.model.chat.ChatMessage
import com.kikidan.domain.model.chat.MessageRole
import com.kikidan.domain.model.chat.MessageStatus
import kotlinx.serialization.Serializable
import java.time.LocalDate
import java.time.LocalDate.parse

@Serializable
data class ChatMessageResponse(
Expand All @@ -19,3 +27,27 @@ data class ChatActionResponse(
val category: String,
val date: String? = null,
)

internal fun ChatActionResponse.toDomain(): ChatAction =
ChatAction(
type = type.toChatActionType(),
label = label,
category = category,
date = date?.let(LocalDate::parse),
)

internal fun ChatMessageResponse.toDomain(): ChatMessage =
ChatMessage(
id = id,
role = role.toMessageRole(),
content = content,
status = status.toMessageStatus(),
action = action?.toDomain(),
createdAt = createdAt.toInstantOrThrow(),
)

private fun String.toMessageRole(): MessageRole = MessageRole.valueOf(uppercase())

private fun String.toMessageStatus(): MessageStatus = MessageStatus.valueOf(uppercase())

private fun String.toChatActionType(): ChatActionType = ChatActionType.valueOf(uppercase())
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package com.kikidan.data_remote.dto.chat

import com.kikidan.data_remote.di.TodakunJson
import com.kikidan.domain.model.chat.ChatQuota
import com.kikidan.domain.model.chat.ChatStreamEvent
import io.ktor.sse.ServerSentEvent
import kotlinx.serialization.Serializable

// SSE 이벤트 data payload. 백엔드 이벤트 DTO와 1:1이며 전 필드 non-null이다.
Expand Down Expand Up @@ -28,3 +32,54 @@ data class ChatStreamErrorResponse(
val code: String,
val message: String,
)

internal fun ServerSentEvent.toChatStreamEventOrNull(): ChatStreamEvent? {
val payload = data ?: return null
return when (event) {
EVENT_START -> {
val dto = TodakunJson.decodeFromString<ChatStreamStartResponse>(payload)
ChatStreamEvent.Start(
conversationId = dto.conversationId,
userMessageId = dto.userMessageId,
assistantMessageId = dto.assistantMessageId,
quota = ChatQuota(used = dto.quotaUsed, limit = dto.quotaLimit),
)
}

EVENT_DELTA -> {
ChatStreamEvent.Delta(
TodakunJson.decodeFromString<ChatStreamDeltaResponse>(payload).text,
)
}

EVENT_ACTION -> {
ChatStreamEvent.Action(
TodakunJson.decodeFromString<ChatActionResponse>(payload).toDomain(),
)
}

EVENT_DONE -> {
ChatStreamEvent.Done(
TodakunJson.decodeFromString<ChatStreamDoneResponse>(payload).assistantMessageId,
)
}

EVENT_ERROR -> {
val dto = TodakunJson.decodeFromString<ChatStreamErrorResponse>(payload)
ChatStreamEvent.Error(
dto.code,
dto.message,
)
}

else -> {
null
}
}
}

private const val EVENT_START = "start"
private const val EVENT_DELTA = "delta"
private const val EVENT_ACTION = "action"
private const val EVENT_DONE = "done"
private const val EVENT_ERROR = "error"
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.kikidan.data_remote.dto.chat

import com.kikidan.domain.model.chat.Conversation
import kotlinx.serialization.Serializable

@Serializable
Expand All @@ -8,3 +9,6 @@ data class ConversationDetailResponse(
val title: String,
val messages: List<ChatMessageResponse> = emptyList(),
)

internal fun ConversationDetailResponse.toDomain(): Conversation =
Conversation(id = id, title = title, messages = messages.map { it.toDomain() })
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package com.kikidan.data_remote.dto.chat

import com.kikidan.data_remote.util.toInstantOrThrow
import com.kikidan.domain.model.chat.ConversationSummary
import kotlinx.serialization.Serializable
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId

@Serializable
data class ConversationListResponse(
Expand All @@ -14,3 +19,13 @@ data class ConversationSummaryResponse(
val lastMessageAt: String,
val unread: Boolean = false,
)

internal fun ConversationListResponse.toDomain(): List<ConversationSummary> = conversations.map { it.toDomain() }

internal fun ConversationSummaryResponse.toDomain(): ConversationSummary =
ConversationSummary(
id = id,
title = title,
lastMessageAt = lastMessageAt.toInstantOrThrow(),
unread = unread,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.kikidan.data_remote.util

import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId

internal fun String.toInstantOrThrow(): Instant =
runCatching { Instant.parse(this) }
.getOrElse { LocalDateTime.parse(this).atZone(KST).toInstant() }

private val KST: ZoneId = ZoneId.of("Asia/Seoul")
Loading
Loading