-
Notifications
You must be signed in to change notification settings - Fork 0
[Feat] ChatViewModel 및 TyprWriterFlow 작성 #86
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
212c3cf
8e1baed
bd20939
c4127a7
69c6776
65d2eaa
41e7fd1
93ebe37
7476a52
15b47c9
e690c2a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| plugins { | ||
| alias(libs.plugins.todakun.feature) | ||
| } | ||
|
|
||
| android { | ||
| namespace = "com.kikidan.chat" | ||
|
|
||
| defaultConfig { | ||
| testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" | ||
| consumerProguardFiles("consumer-rules.pro") | ||
| } | ||
| } | ||
|
|
||
| dependencies { | ||
| implementation(libs.androidx.compose.material3) | ||
| implementation(libs.androidx.core.ktx) | ||
| implementation(libs.kotlinx.coroutines.core) | ||
|
|
||
| testImplementation(libs.kotlinx.coroutines.test) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| <?xml version="1.0" encoding="utf-8"?> | ||
| <manifest /> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,234 @@ | ||
| package com.kikidan.chat | ||
|
|
||
| import androidx.lifecycle.ViewModel | ||
| import com.kikidan.chat.model.ChatSideEffect | ||
| import com.kikidan.chat.model.ChatState | ||
| import com.kikidan.chat.model.StreamingChatState | ||
| import com.kikidan.domain.model.chat.ChatAction | ||
| import com.kikidan.domain.model.chat.ChatMessage | ||
| import com.kikidan.domain.model.chat.ChatStreamEvent | ||
| import com.kikidan.domain.model.chat.MessageRole | ||
| import com.kikidan.domain.model.chat.MessageStatus | ||
| import com.kikidan.domain.usecase.GetChatEntryUseCase | ||
| import com.kikidan.domain.usecase.GetConversationDetailUseCase | ||
| import com.kikidan.domain.usecase.SendChatMessageUseCase | ||
| import dagger.hilt.android.lifecycle.HiltViewModel | ||
| import kotlinx.collections.immutable.persistentListOf | ||
| import kotlinx.collections.immutable.toPersistentList | ||
| import kotlinx.coroutines.flow.transform | ||
| import org.orbitmvi.orbit.ContainerHost | ||
| import org.orbitmvi.orbit.syntax.Syntax | ||
| import org.orbitmvi.orbit.viewmodel.container | ||
| import java.time.Instant | ||
| import javax.inject.Inject | ||
| import kotlin.coroutines.cancellation.CancellationException | ||
|
|
||
| @HiltViewModel | ||
| class ChatViewModel | ||
| @Inject | ||
| constructor( | ||
| private val getChatEntry: GetChatEntryUseCase, | ||
| private val getConversationDetail: GetConversationDetailUseCase, | ||
| private val sendChatMessage: SendChatMessageUseCase, | ||
| ) : ViewModel(), | ||
| ContainerHost<ChatState, ChatSideEffect> { | ||
| override val container = container<ChatState, ChatSideEffect>(ChatState()) | ||
|
|
||
| /** 화면 진입 시 1회. conversationId가 있으면 과거 대화를 먼저 채운다. */ | ||
| fun load(conversationId: String?) = | ||
| intent { | ||
| reduce { state.copy(conversationId = conversationId, isLoading = true) } | ||
|
|
||
| getChatEntry() | ||
| .onSuccess { entry -> | ||
| reduce { | ||
| state.copy( | ||
| greeting = entry.greeting, | ||
| suggestions = entry.suggestions.toPersistentList(), | ||
| quota = entry.quota, | ||
| ) | ||
| } | ||
| }.onFailure { postSideEffect(ChatSideEffect.Error(it)) } | ||
|
|
||
| if (conversationId != null) { | ||
| getConversationDetail(conversationId) | ||
| .onSuccess { reduce { state.copy(messages = it.messages.toPersistentList()) } } | ||
| .onFailure { postSideEffect(ChatSideEffect.Error(it)) } | ||
| } | ||
|
|
||
| reduce { state.copy(isLoading = false) } | ||
| } | ||
|
|
||
| fun onInputChange(value: String) = | ||
| intent { | ||
| reduce { state.copy(input = value.take(SendChatMessageUseCase.MAX_CONTENT_LENGTH)) } | ||
| } | ||
|
|
||
| fun onSendClick() = | ||
| intent { | ||
| val content = state.input | ||
| reduce { state.copy(input = "") } | ||
| send(content) | ||
| } | ||
|
|
||
| fun onSuggestionClick(seedPrompt: String) = intent { send(seedPrompt) } | ||
|
|
||
| fun startNewConversation() = | ||
| intent { | ||
| reduce { | ||
| state.copy( | ||
| conversationId = null, | ||
| messages = persistentListOf(), | ||
| streamingChatState = StreamingChatState.Idle, | ||
| input = "", | ||
| ) | ||
| } | ||
| } | ||
|
Comment on lines
+76
to
+86
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 Result: In Orbit MVI (Kotlin), intents are processed sequentially per container by default (not parallel), because each container uses an “event loop” coroutine that receives intent jobs from a channel and then launches each intent coroutine in turn; the next intent is taken from the channel only after the current loop iteration proceeds. The default threading model is configured via RealSettings: intents are launched using intentLaunchingDispatcher (default Dispatchers.Unconfined) and the container’s event-loop runs on eventLoopDispatcher (default Dispatchers.Default) [1][2]. What this means for “sequential vs parallel”: 1) Ordering of intents: Orbit processes intent requests through an internal dispatchChannel + event-loop, which provides a queue/serialized intake per container [1]. 2) Parallel decomposition within a single intent: you can run multiple sub-blocks concurrently by using coroutineScope { launch { … } }. Orbit provides subIntent “for parallel decomposition or subdivision of a larger intent into smaller parts” [3][4]. Those launched coroutines run concurrently as normal Kotlin coroutines within that one intent’s scope. 3) Parallelism across containers: dispatcher settings are per-container instances (factories invoked once per container), so different containers can run intents independently; also Orbit.configureDefaults notes serialization can be limited per-container depending on how you set intentLaunchingDispatcher [4]. Defaults you can rely on: - Calls to Container.intent do not block the caller; Orbit offloads the work to an event-loop style background coroutine, and long-running/blocking work inside intents can block that event-loop [4]. - sideEffectMode defaults to FAN_OUT, but that pertains to side-effect delivery rather than intent execution ordering [2]. To make intent execution parallel, you generally wouldn’t change “default intent queueing” directly; instead you’d keep intents serialized but fetch independent data in parallel within a single intent using coroutineScope/async and then reduce once (or use subIntent for decomposing/launching concurrent collectors inside the intent) [3][4]. Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== Locate ChatViewModel and related search results =="
fd -a 'ChatViewModel\.kt$' . | sed 's#^\./##'
echo
echo "== Candidate file outline =="
file="$(fd 'ChatViewModel.kt$' . | head -n 1 || true)"
if [ -n "${file:-}" ]; then
wc -l "$file"
ast-grep outline "$file" --view compact || true
echo
echo "== Relevant ChatViewModel sections =="
sed -n '1,260p' "$file" | cat -n
fi
echo
echo "== Search streaming/state handling symbols =="
rg -n "streamConversationId|streamingChatState|startNewConversation|onSteamingDone|streamConversation|cancel|Job|CoroutineScope|container|Orbit" . --glob '*.kt' -C 2
echo
echo "== Build config orbit versions =="
rg -n "orbit|kotlin-coroutines|coroutines" -S . --glob 'build.gradle*' --glob '*.gradle.kts' --glob 'libs.versions.toml' -C 1 || trueRepository: YAPP-Github/28th-App-Team-2-Android Length of output: 50391 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== ChatViewModel tests around concurrent intent/startNewConversation =="
sed -n '350,430p' feature/chat/src/test/java/com/kikidan/chat/ChatViewModelTest.kt | cat -n
echo
echo "== ChatViewModel tests around send concurrency =="
sed -n '184,370p' feature/chat/src/test/java/com/kikidan/chat/ChatViewModelTest.kt | cat -n
echo
echo "== SendChatMessage remote repo flow implementation outline =="
sed -n '1,240p' core/data/src/main/java/com/kikidan/data/repository/ChatRepositoryImpl.kt | cat -n
sed -n '1,260p' core/data-remote/src/main/java/com/kikidan/data_remote/datasource/ChatRemoteDataSourceImpl.kt | cat -n 2>/dev/null || true
echo
echo "== Gradle orbit/coroutines declarations =="
rg -n "orbit|kotlinx-coroutines|coroutines" -S . --glob 'build.gradle*' --glob '*.gradle.kts' --glob 'libs.versions.toml' --glob 'gradle/libs.versions.toml' -C 1Repository: YAPP-Github/28th-App-Team-2-Android Length of output: 20172 [P2] 새 대화 시작 시 진행 중인 응답 흐름도 함께 종료해야 합니다.
🤖 Prompt for AI Agents |
||
|
|
||
| // 전송 진입점이 여러 개이므로 가드를 여기 한 곳에만 둔다 (설계 2-7). | ||
| private suspend fun Syntax<ChatState, ChatSideEffect>.send(content: String) { | ||
| if (state.streamingChatState !is StreamingChatState.Idle) return | ||
|
|
||
| val conversationId = state.conversationId | ||
| val placeholder = localUserMessage(content.trim()) | ||
| reduce { | ||
| state.copy( | ||
| messages = state.messages.adding(placeholder), | ||
| streamingChatState = StreamingChatState.Thinking, | ||
| ) | ||
| } | ||
|
|
||
| var streamConversationId: String? = conversationId | ||
| var assistantMessageId: String? = null | ||
| var pendingAction: ChatAction? = null | ||
|
|
||
| try { | ||
| sendChatMessage(conversationId, content) | ||
| .transform { result -> | ||
| when (val event = result.getOrElse { throw it }) { | ||
| is ChatStreamEvent.Start -> { | ||
| val result = onStreamingStart(placeholder, event) | ||
| streamConversationId = result.first | ||
| assistantMessageId = result.second | ||
| } | ||
|
|
||
| is ChatStreamEvent.Delta -> { | ||
| emit(event.text) | ||
| } | ||
|
|
||
| is ChatStreamEvent.Action -> { | ||
| pendingAction = event.action | ||
| } | ||
|
|
||
| is ChatStreamEvent.Done -> { | ||
| assistantMessageId = event.assistantMessageId | ||
| } | ||
|
|
||
| is ChatStreamEvent.Error -> { | ||
| postSideEffect(ChatSideEffect.ShowStreamingErrorMessage(event.message)) | ||
| } | ||
| } | ||
| }.typewriter() | ||
| .collect { shown -> | ||
| reduce { | ||
| state.copy( | ||
| streamingChatState = StreamingChatState.Typing(shown), | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| onSteamingDone( | ||
| streamConversationId, | ||
| assistantMessageId, | ||
| pendingAction, | ||
| ) | ||
| } catch (e: CancellationException) { | ||
| throw e | ||
| } catch (e: Throwable) { | ||
| reduce { | ||
| state.copy( | ||
| streamingChatState = StreamingChatState.Idle, | ||
| ) | ||
| } | ||
| postSideEffect(ChatSideEffect.Error(e)) | ||
| } | ||
|
Comment on lines
+145
to
+154
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win [P2] 전송 실패 시 낙관적 사용자 메시지가 성공 상태로 남습니다.
낙관적 메시지는 전송 대기 상태로 생성하고, 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| private suspend fun Syntax<ChatState, ChatSideEffect>.onStreamingStart( | ||
| placeholder: ChatMessage, | ||
| event: ChatStreamEvent.Start, | ||
| ): Pair<String, String> { | ||
| reduce { | ||
| state.copy( | ||
| // 낙관적 메시지의 로컬 id를 서버가 준 진짜 id로 교체. | ||
| messages = | ||
| state.messages | ||
| .map { msg -> | ||
| if (msg.id == placeholder.id) { | ||
| msg.copy( | ||
| id = event.userMessageId, | ||
| status = MessageStatus.COMPLETED, | ||
| ) | ||
| } else { | ||
| msg | ||
| } | ||
| }.toPersistentList(), | ||
| quota = event.quota, | ||
| ) | ||
| } | ||
| return event.conversationId to event.assistantMessageId | ||
| } | ||
|
|
||
| private suspend fun Syntax<ChatState, ChatSideEffect>.onSteamingDone( | ||
| streamConversationId: String?, | ||
| assistantMessageId: String?, | ||
| pendingAction: ChatAction?, | ||
| ) { | ||
| reduce { | ||
| val currentStreamingState = state.streamingChatState | ||
| state.copy( | ||
| conversationId = streamConversationId, | ||
| messages = | ||
| if (currentStreamingState is StreamingChatState.Typing) { | ||
| state.messages.adding( | ||
| assistantMessage( | ||
| id = assistantMessageId, | ||
| content = currentStreamingState.streamingText, | ||
| action = pendingAction, | ||
| ), | ||
| ) | ||
| } else { | ||
| state.messages | ||
| }, | ||
| streamingChatState = StreamingChatState.Idle, | ||
| ) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private fun localUserMessage(content: String) = | ||
| ChatMessage( | ||
| id = "local-user-${System.currentTimeMillis()}", | ||
| role = MessageRole.USER, | ||
| content = content, | ||
| status = MessageStatus.COMPLETED, | ||
| action = null, | ||
| createdAt = Instant.now(), | ||
| ) | ||
|
|
||
| /** | ||
| * 서버는 done에 텍스트/시각을 싣지 않으므로(assistantMessageId만 전달) 최종 메시지는 여기서 조립한다. | ||
| * id는 start/done이 준 값을 쓰되, start도 못 받고 스트림이 끝난 경우를 대비해 로컬 id로 폴백한다. | ||
| */ | ||
| private fun assistantMessage( | ||
| id: String?, | ||
| content: String, | ||
| action: ChatAction?, | ||
| ) = ChatMessage( | ||
| id = id ?: "local-assistant-${System.currentTimeMillis()}", | ||
| role = MessageRole.ASSISTANT, | ||
| content = content, | ||
| status = MessageStatus.COMPLETED, | ||
| action = action, | ||
| createdAt = Instant.now(), | ||
| ) | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,37 @@ | ||||||||||||||||||
| package com.kikidan.chat | ||||||||||||||||||
|
|
||||||||||||||||||
| import kotlinx.coroutines.delay | ||||||||||||||||||
| import kotlinx.coroutines.flow.Flow | ||||||||||||||||||
| import kotlinx.coroutines.flow.channelFlow | ||||||||||||||||||
| import kotlinx.coroutines.launch | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * 도착 속도(네트워크)와 표시 속도(화면)를 분리한다. | ||||||||||||||||||
| * 업스트림 청크를 버퍼에 쌓고 tick마다 조금씩 잘라 지금까지 보여줄 전체 텍스"를 방출한다. | ||||||||||||||||||
| */ | ||||||||||||||||||
|
Comment on lines
+8
to
+11
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win [P3] KDoc 오타를 수정하십시오.
✏️ 제안 수정- * 업스트림 청크를 버퍼에 쌓고 tick마다 조금씩 잘라 지금까지 보여줄 전체 텍스"를 방출한다.
+ * 업스트림 청크를 버퍼에 쌓고 tick마다 조금씩 잘라 지금까지 보여줄 전체 텍스트를 방출한다.📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
| internal fun Flow<String>.typewriter(tickMillis: Long = 16L): Flow<String> = | ||||||||||||||||||
| channelFlow { | ||||||||||||||||||
| val buffered = StringBuffer() | ||||||||||||||||||
| val upstream = launch { this@typewriter.collect { chunk -> buffered.append(chunk) } } | ||||||||||||||||||
| var shown = 0 | ||||||||||||||||||
| while (true) { | ||||||||||||||||||
| val upstreamDone = !upstream.isActive | ||||||||||||||||||
| when { | ||||||||||||||||||
| shown < buffered.length -> { | ||||||||||||||||||
| shown = | ||||||||||||||||||
| (((buffered.length - shown) / CATCH_UP_DIVISOR) + shown + 1) | ||||||||||||||||||
| .coerceAtMost(buffered.length) | ||||||||||||||||||
| send(buffered.substring(0, shown)) | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| upstreamDone -> { | ||||||||||||||||||
| return@channelFlow | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| delay(tickMillis) | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // 남은 글자의 1/32 를 매 틱 추가 방출, | ||||||||||||||||||
| // 약 0.35초에 남은 buffer의 절반을 채우는 속도, tickMillis 파라미터와 더불어 실기기에서 체크 후 조정 요망 | ||||||||||||||||||
| private const val CATCH_UP_DIVISOR = 32 | ||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package com.kikidan.chat.model | ||
|
|
||
| sealed interface ChatSideEffect { | ||
| data class ShowStreamingErrorMessage( | ||
| val message: String, | ||
| ) : ChatSideEffect | ||
|
|
||
| data class Error( | ||
| val e: Throwable, | ||
| ) : ChatSideEffect | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| package com.kikidan.chat.model | ||
|
|
||
| import com.kikidan.domain.model.chat.ChatMessage | ||
| import com.kikidan.domain.model.chat.ChatQuota | ||
| import com.kikidan.domain.model.chat.ChatSuggestion | ||
| import kotlinx.collections.immutable.PersistentList | ||
| import kotlinx.collections.immutable.persistentListOf | ||
|
|
||
| data class ChatState( | ||
| val conversationId: String? = null, | ||
| val isLoading: Boolean = true, | ||
| val greeting: String = "", | ||
| val suggestions: PersistentList<ChatSuggestion> = persistentListOf(), | ||
| val quota: ChatQuota? = null, | ||
| val messages: PersistentList<ChatMessage> = persistentListOf(), | ||
| val input: String = "", | ||
| val streamingChatState: StreamingChatState = StreamingChatState.Idle, | ||
| ) | ||
|
|
||
| sealed interface StreamingChatState { | ||
| data object Idle : StreamingChatState | ||
|
|
||
| data object Thinking : StreamingChatState | ||
|
|
||
| data class Typing( | ||
| val streamingText: String = "", | ||
| ) : StreamingChatState | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win
[P1] 전송 실패 시 입력값이 유실되고, 공백 입력이 그대로 전송됩니다.
onSendClick은send호출 전에input을 비웁니다.send의 가드(Line 88)가 스트리밍 중 즉시 return하면 사용자가 작성한 텍스트가 사라집니다.또한 공백 검사가 없습니다. 공백만 입력해도 서버로 전송되고 빈 placeholder가 messages에 추가됩니다.
추가로 placeholder는
content.trim()(Line 91)을 쓰지만sendChatMessage에는 원본content(Line 104)를 전달합니다. 화면 표시 값과 서버 전송 값이 달라집니다.send진입 시 한 번 trim하여 두 경로에 같은 값을 쓰십시오.🐛 제안 수정
fun onSendClick() = intent { - val content = state.input - reduce { state.copy(input = "") } - send(content) + send(state.input) }send내부에서 정규화와 입력 초기화를 함께 처리합니다.private suspend fun Syntax<ChatState, ChatSideEffect>.send(content: String) { if (state.streamingChatState !is StreamingChatState.Idle) return + val trimmed = content.trim() + if (trimmed.isBlank()) return val conversationId = state.conversationId - val placeholder = localUserMessage(content.trim()) + val placeholder = localUserMessage(trimmed) reduce { state.copy( messages = state.messages + placeholder, streamingChatState = StreamingChatState.Thinking, + input = "", ) }그리고 Line 104의 전송도 정규화된 값을 사용합니다.
📝 Committable suggestion
🤖 Prompt for AI Agents