diff --git a/AIProject/iCo/Features/ChatBot/View/ChatInputView.swift b/AIProject/iCo/Features/ChatBot/View/ChatInputView.swift index 95d0b19d..33888fd2 100644 --- a/AIProject/iCo/Features/ChatBot/View/ChatInputView.swift +++ b/AIProject/iCo/Features/ChatBot/View/ChatInputView.swift @@ -24,20 +24,21 @@ struct ChatInputView: View { Button { Task { await viewModel.sendMessage(message: viewModel.searchText) } } label: { - Image(systemName: "arrow.up") + Image(systemName: viewModel.isStreaming ? "square.fill" : "arrow.up") + .foregroundStyle(viewModel.isEditable && !viewModel.isStreaming ? .aiCoAccent : .aiCoNeutral) .padding(10) } .frame(width: 30, height: 30) .background { Circle() - .fill(viewModel.isEditable ? .aiCoBackgroundAccent : .aiCoBackgroundWhite) + .fill(viewModel.isEditable && !viewModel.isStreaming ? .aiCoBackgroundAccent : .aiCoBackgroundWhite) } .onChange(of: viewModel.isTapped) { isFocused = false } .overlay { Circle() - .strokeBorder(viewModel.isEditable ? .accentGradient : .defaultGradient, lineWidth: 0.5) + .strokeBorder(viewModel.isEditable && !viewModel.isStreaming ? .accentGradient : .defaultGradient, lineWidth: 0.5) } .disabled(!viewModel.isEditable) } diff --git a/AIProject/iCo/Features/ChatBot/ViewModel/ChatBotViewModel.swift b/AIProject/iCo/Features/ChatBot/ViewModel/ChatBotViewModel.swift index 8cee160c..6a5c88d6 100644 --- a/AIProject/iCo/Features/ChatBot/ViewModel/ChatBotViewModel.swift +++ b/AIProject/iCo/Features/ChatBot/ViewModel/ChatBotViewModel.swift @@ -35,46 +35,64 @@ final class ChatBotViewModel: ObservableObject { /// 서버와 통신하는 클라이언트입니다. private let chatBotClient: ChatBotClient + /// 메세지 전송 스트림 작업입니다. + private var streamTask: Task? init(chatBotClient: ChatBotClient = ChatBotClient()) { self.chatBotClient = chatBotClient } /// 사용자가 입력한 메시지를 전송하고, 챗봇 응답 스트림을 관찰하여 UI에 반영합니다. + /// 만약 메세지가 이미 전송 중인 상태라면, 메세지 전송을 중단합니다. /// - Parameter content: 사용자가 전송하는 메세지 내용입니다. /// /// 이 메소드는 메인 쓰레드에서 실행됩니다. @MainActor func sendMessage(message: String) async { - searchText = "" - isStreaming = true - - do { + if isStreaming { + cancelStream() + } else { + searchText = "" + isStreaming = true addMessage(with: message) isReceived = true - try await chatBotClient.connect(content: message) - try await observeStream() + + do { + try await chatBotClient.connect(content: message) + try await observeStream() + } catch let error as NetworkError { + switch error { + case .taskCancelled: + chatBotClient.disconnect() + default: + chatBotClient.disconnect() + showStreamError() + } + } catch { + print("알 수 없는 에러 발생.") + } + isReceived = false - } catch { - await MainActor.run { showStreamError() } + isStreaming = false } - - isStreaming = false } /// 챗봇 SSE 스트림을 관찰하여 토큰 단위로 UI에 메시지를 업데이트합니다. private func observeStream() async throws { guard let stream = chatBotClient.stream else { return } - for try await content in stream { - try await Task.sleep(for: .seconds(0.05)) - await MainActor.run { - if let index = messages.lastIndex(where: { !$0.isUser }) { - let message = messages[index] - messages[index] = ChatMessage(content: message.content + content, isUser: false) + streamTask = Task { + for try await content in stream { + await MainActor.run { + if let index = messages.lastIndex(where: { !$0.isUser }) { + let message = messages[index] + messages[index] = ChatMessage(content: message.content + content, isUser: false) + } } } } + + try await streamTask?.value } /// 사용자 메시지와 빈 챗봇 응답 메시지를 목록에 추가합니다. @@ -92,7 +110,7 @@ final class ChatBotViewModel: ObservableObject { /// 이 메소드는 메인 쓰레드에서 실행됩니다. @MainActor private func checkValid() { - isEditable = !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isStreaming + isEditable = !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isStreaming } /// SSE 데이터 전달 과정에서 에러가 발생했을 때 호출합니다. @@ -104,4 +122,14 @@ final class ChatBotViewModel: ObservableObject { messages[index] = ChatMessage(content: "알 수 없는 에러가 발생했습니다.", isUser: false, isError: true) } } + + /// 메세지 전송을 취소합니다. + /// + /// 이 메소드는 메인 쓰레드에서 실행됩니다. + @MainActor + private func cancelStream() { + chatBotClient.continuation?.finish(throwing: NetworkError.taskCancelled) + streamTask?.cancel() + streamTask = nil + } }