Skip to content
Draft
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
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ dependencies {
implementation(projects.core.navigation)
implementation(projects.core.designsystem)
implementation(projects.feature.auth)
implementation(projects.feature.chat)

implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
Expand Down
20 changes: 20 additions & 0 deletions feature/chat/build.gradle.kts
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)
}
Empty file added feature/chat/consumer-rules.pro
Empty file.
2 changes: 2 additions & 0 deletions feature/chat/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
234 changes: 234 additions & 0 deletions feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt
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)
}
Comment on lines +67 to +72

Copy link
Copy Markdown

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] 전송 실패 시 입력값이 유실되고, 공백 입력이 그대로 전송됩니다.

onSendClicksend 호출 전에 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의 전송도 정규화된 값을 사용합니다.

-                sendChatMessage(conversationId, content)
+                sendChatMessage(conversationId, trimmed)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fun onSendClick() =
intent {
val content = state.input
reduce { state.copy(input = "") }
send(content)
}
fun onSendClick() =
intent {
send(state.input)
}
Suggested change
fun onSendClick() =
intent {
val content = state.input
reduce { state.copy(input = "") }
send(content)
}
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(trimmed)
reduce {
state.copy(
messages = state.messages + placeholder,
streamingChatState = StreamingChatState.Thinking,
input = "",
)
}
Suggested change
fun onSendClick() =
intent {
val content = state.input
reduce { state.copy(input = "") }
send(content)
}
sendChatMessage(conversationId, trimmed)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt` around lines 65
- 70, Update onSendClick and send so input normalization and clearing occur
inside send: trim the content once on entry, return without clearing or sending
when it is blank or while streaming, and use the same trimmed value for the
placeholder and sendChatMessage. Remove the premature state.copy(input = "")
from onSendClick.


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Orbit MVI container intent execution sequential or parallel by default

💡 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 || true

Repository: 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 1

Repository: YAPP-Github/28th-App-Team-2-Android

Length of output: 20172


[P2] 새 대화 시작 시 진행 중인 응답 흐름도 함께 종료해야 합니다.

startNewConversation은 상태만 초기화하지만, send()streamStreamingIdassistantMessageId를 캡처한 채 Flow를 계속 수집합니다. 이전에 선택한 대화의 Done 이벤트가 새 conversationId/답변으로 덮어씌워질 수 있습니다. 새 대화 요청 시 프록시 상태가 실제 대화 식별자와 여전히 일치하는지 확인한 뒤에만 최종 적용하거나, 새 대화 요청으로 진행 중인 job을 취소하십시오.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt` around lines 74
- 84, Update startNewConversation and the send() streaming flow so an
in-progress response from the previous conversation cannot update the new
conversation state: either cancel the active streaming job when starting a new
conversation, or validate the captured stream/conversation identifiers before
applying terminal events such as Done. Ensure stale events are ignored after the
conversation is reset.


// 전송 진입점이 여러 개이므로 가드를 여기 한 곳에만 둔다 (설계 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

[P2] 전송 실패 시 낙관적 사용자 메시지가 성공 상태로 남습니다.

localUserMessage(Line 210)는 서버 확인 전에 MessageStatus.COMPLETED를 설정합니다. 스트림이 실패하면 이 메시지는 계속 성공 상태로 표시됩니다.

낙관적 메시지는 전송 대기 상태로 생성하고, ChatStreamEvent.Start 수신 시 COMPLETED로 갱신하십시오. 실패 시에는 catch 블록에서 실패 상태로 표시해 재전송 UI를 제공할 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@feature/chat/src/main/java/com/kikidan/chat/ChatViewModel.kt` around lines
143 - 152, Update localUserMessage creation to use a pending status instead of
MessageStatus.COMPLETED, then transition it to COMPLETED when
ChatStreamEvent.Start is received. In the Throwable catch block, mark the
optimistic message as failed while preserving the existing streaming state reset
and error side effect so the resend UI can appear.

}

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(),
)
37 changes: 37 additions & 0 deletions feature/chat/src/main/java/com/kikidan/chat/TypewriterFlow.kt
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

[P3] KDoc 오타를 수정하십시오.

전체 텍스"를에 잘못된 따옴표가 있습니다.

✏️ 제안 수정
- * 업스트림 청크를 버퍼에 쌓고 tick마다 조금씩 잘라 지금까지 보여줄 전체 텍스"를 방출한다.
+ * 업스트림 청크를 버퍼에 쌓고 tick마다 조금씩 잘라 지금까지 보여줄 전체 텍스트를 방출한다.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* 도착 속도(네트워크)와 표시 속도(화면)를 분리한다.
* 업스트림 청크를 버퍼에 쌓고 tick마다 조금씩 잘라 지금까지 보여줄 전체 텍스" 방출한다.
*/
/**
* 도착 속도(네트워크)와 표시 속도(화면)를 분리한다.
* 업스트림 청크를 버퍼에 쌓고 tick마다 조금씩 잘라 지금까지 보여줄 전체 텍스트를 방출한다.
*/
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@feature/chat/src/main/java/com/kikidan/chat/TypewriterFlow.kt` around lines 8
- 11, TypewriterFlow의 KDoc 설명에서 잘못된 따옴표를 제거하여 `전체 텍스"를`을 올바른 문구로 수정하십시오.

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
}
28 changes: 28 additions & 0 deletions feature/chat/src/main/java/com/kikidan/chat/model/ChatState.kt
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
}
Loading
Loading