diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/di/NetworkExtension.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/di/NetworkExtension.kt index 62034af..73d3773 100644 --- a/core/data-remote/src/main/java/com/kikidan/data_remote/di/NetworkExtension.kt +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/di/NetworkExtension.kt @@ -9,6 +9,7 @@ import io.ktor.client.plugins.auth.Auth import io.ktor.client.plugins.auth.providers.BearerTokens import io.ktor.client.plugins.auth.providers.bearer import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.sse.SSE import io.ktor.http.ContentType import io.ktor.http.contentType import io.ktor.serialization.kotlinx.json.json @@ -30,6 +31,7 @@ internal fun HttpClientConfig<*>.installTodakunDefaults( url(baseUrl) contentType(ContentType.Application.Json) } + install(SSE) expectSuccess = true } diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatEntryResponse.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatEntryResponse.kt new file mode 100644 index 0000000..fa0dcfa --- /dev/null +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatEntryResponse.kt @@ -0,0 +1,24 @@ +package com.kikidan.data_remote.dto.chat + +import kotlinx.serialization.Serializable + +@Serializable +data class ChatEntryResponse( + val greeting: String, + val suggestions: List = emptyList(), + val quota: ChatQuotaResponse, +) + +@Serializable +data class ChatSuggestionResponse( + val emoji: String, + val label: String, + val seedPrompt: String, + val category: String, +) + +@Serializable +data class ChatQuotaResponse( + val used: Int, + val limit: Int, +) diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatMessageResponse.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatMessageResponse.kt new file mode 100644 index 0000000..fd73d96 --- /dev/null +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatMessageResponse.kt @@ -0,0 +1,21 @@ +package com.kikidan.data_remote.dto.chat + +import kotlinx.serialization.Serializable + +@Serializable +data class ChatMessageResponse( + val id: String, + val role: String, + val content: String, + val status: String, + val action: ChatActionResponse? = null, + val createdAt: String, +) + +@Serializable +data class ChatActionResponse( + val type: String, + val label: String, + val category: String, + val date: String? = null, +) diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatStreamResponse.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatStreamResponse.kt new file mode 100644 index 0000000..ce36627 --- /dev/null +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatStreamResponse.kt @@ -0,0 +1,30 @@ +package com.kikidan.data_remote.dto.chat + +import kotlinx.serialization.Serializable + +// SSE 이벤트 data payload. 백엔드 이벤트 DTO와 1:1이며 전 필드 non-null이다. + +@Serializable +data class ChatStreamStartResponse( + val conversationId: String, + val userMessageId: String, + val assistantMessageId: String, + val quotaUsed: Int, + val quotaLimit: Int, +) + +@Serializable +data class ChatStreamDeltaResponse( + val text: String, +) + +@Serializable +data class ChatStreamDoneResponse( + val assistantMessageId: String, +) + +@Serializable +data class ChatStreamErrorResponse( + val code: String, + val message: String, +) diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ConversationDetailResponse.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ConversationDetailResponse.kt new file mode 100644 index 0000000..f9adb8e --- /dev/null +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ConversationDetailResponse.kt @@ -0,0 +1,10 @@ +package com.kikidan.data_remote.dto.chat + +import kotlinx.serialization.Serializable + +@Serializable +data class ConversationDetailResponse( + val id: String, + val title: String, + val messages: List = emptyList(), +) diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ConversationListResponse.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ConversationListResponse.kt new file mode 100644 index 0000000..c1fcad7 --- /dev/null +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ConversationListResponse.kt @@ -0,0 +1,16 @@ +package com.kikidan.data_remote.dto.chat + +import kotlinx.serialization.Serializable + +@Serializable +data class ConversationListResponse( + val conversations: List = emptyList(), +) + +@Serializable +data class ConversationSummaryResponse( + val id: String, + val title: String, + val lastMessageAt: String, + val unread: Boolean = false, +) diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/SendChatMessageRequest.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/SendChatMessageRequest.kt new file mode 100644 index 0000000..a6b104e --- /dev/null +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/SendChatMessageRequest.kt @@ -0,0 +1,9 @@ +package com.kikidan.data_remote.dto.chat + +import kotlinx.serialization.Serializable + +@Serializable +data class SendChatMessageRequest( + val conversationId: String? = null, + val content: String, +) diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/sse/ServerSentEvents.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/sse/ServerSentEvents.kt new file mode 100644 index 0000000..c278f39 --- /dev/null +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/sse/ServerSentEvents.kt @@ -0,0 +1,38 @@ +package com.kikidan.data_remote.sse + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpTimeoutConfig +import io.ktor.client.plugins.sse.sse +import io.ktor.client.plugins.timeout +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.sse.ServerSentEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +// 이벤트 간 무응답 상한. 무한으로 두면 서버가 조용히 죽었을 때 앱이 영원히 대기한다. +private const val SSE_SOCKET_TIMEOUT_MS = 60_000L + +/** + * SSE 엔드포인트를 열어 서버 이벤트를 Flow로 방출한다. + * collect가 취소되면 연결도 닫힌다(sse()의 세션 수명이 곧 이 Flow의 수명). 비-2xx 응답은 collect 시점에 예외로 전파된다. + */ +internal fun HttpClient.serverSentEvents( + urlString: String, + block: HttpRequestBuilder.() -> Unit = {}, +): Flow = + flow { + sse( + urlString = urlString, + request = { + block() + // 전역 HttpTimeout(requestTimeoutMillis=15s)이 장시간 스트림을 끊으므로 반드시 덮어쓴다. + // SSE 전체 길이 상한은 없애고, 소켓 무응답만 60s로 감지한다. + timeout { + requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS + socketTimeoutMillis = SSE_SOCKET_TIMEOUT_MS + } + }, + ) { + incoming.collect { event -> emit(event) } + } + }