From 82999d7da05fda0a26f29e83ca6604604e276b75 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sun, 12 Oct 2025 00:43:55 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=B1=97=EB=B4=87=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EC=B7=A8=EC=86=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Features/ChatBot/View/ChatInputView.swift | 2 +- .../ChatBot/ViewModel/ChatBotViewModel.swift | 57 +++++++++++++------ 2 files changed, 41 insertions(+), 18 deletions(-) diff --git a/AIProject/iCo/Features/ChatBot/View/ChatInputView.swift b/AIProject/iCo/Features/ChatBot/View/ChatInputView.swift index 95d0b19d..82135514 100644 --- a/AIProject/iCo/Features/ChatBot/View/ChatInputView.swift +++ b/AIProject/iCo/Features/ChatBot/View/ChatInputView.swift @@ -24,7 +24,7 @@ struct ChatInputView: View { Button { Task { await viewModel.sendMessage(message: viewModel.searchText) } } label: { - Image(systemName: "arrow.up") + Image(systemName: viewModel.isStreaming ? "square.fill" : "arrow.up") .padding(10) } .frame(width: 30, height: 30) diff --git a/AIProject/iCo/Features/ChatBot/ViewModel/ChatBotViewModel.swift b/AIProject/iCo/Features/ChatBot/ViewModel/ChatBotViewModel.swift index 8cee160c..c8afd38b 100644 --- a/AIProject/iCo/Features/ChatBot/ViewModel/ChatBotViewModel.swift +++ b/AIProject/iCo/Features/ChatBot/ViewModel/ChatBotViewModel.swift @@ -35,6 +35,7 @@ final class ChatBotViewModel: ObservableObject { /// 서버와 통신하는 클라이언트입니다. private let chatBotClient: ChatBotClient + private var streamTask: Task? init(chatBotClient: ChatBotClient = ChatBotClient()) { self.chatBotClient = chatBotClient @@ -46,35 +47,50 @@ final class ChatBotViewModel: ObservableObject { /// 이 메소드는 메인 쓰레드에서 실행됩니다. @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 +108,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 +120,11 @@ 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 + } } From 99ae05ef0235c80813038ba2621c0fcf08612b2e Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sun, 12 Oct 2025 21:52:11 +0900 Subject: [PATCH 2/3] =?UTF-8?q?chore:=20=EC=A3=BC=EC=84=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../iCo/Features/ChatBot/ViewModel/ChatBotViewModel.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/AIProject/iCo/Features/ChatBot/ViewModel/ChatBotViewModel.swift b/AIProject/iCo/Features/ChatBot/ViewModel/ChatBotViewModel.swift index c8afd38b..6a5c88d6 100644 --- a/AIProject/iCo/Features/ChatBot/ViewModel/ChatBotViewModel.swift +++ b/AIProject/iCo/Features/ChatBot/ViewModel/ChatBotViewModel.swift @@ -35,6 +35,7 @@ final class ChatBotViewModel: ObservableObject { /// 서버와 통신하는 클라이언트입니다. private let chatBotClient: ChatBotClient + /// 메세지 전송 스트림 작업입니다. private var streamTask: Task? init(chatBotClient: ChatBotClient = ChatBotClient()) { @@ -42,6 +43,7 @@ final class ChatBotViewModel: ObservableObject { } /// 사용자가 입력한 메시지를 전송하고, 챗봇 응답 스트림을 관찰하여 UI에 반영합니다. + /// 만약 메세지가 이미 전송 중인 상태라면, 메세지 전송을 중단합니다. /// - Parameter content: 사용자가 전송하는 메세지 내용입니다. /// /// 이 메소드는 메인 쓰레드에서 실행됩니다. @@ -121,6 +123,9 @@ final class ChatBotViewModel: ObservableObject { } } + /// 메세지 전송을 취소합니다. + /// + /// 이 메소드는 메인 쓰레드에서 실행됩니다. @MainActor private func cancelStream() { chatBotClient.continuation?.finish(throwing: NetworkError.taskCancelled) From b56c60050d713967a517faa7dbaed179ddf38374 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Mon, 13 Oct 2025 19:38:12 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20=EC=A0=84=EC=86=A1=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=83=89=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AIProject/iCo/Features/ChatBot/View/ChatInputView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/AIProject/iCo/Features/ChatBot/View/ChatInputView.swift b/AIProject/iCo/Features/ChatBot/View/ChatInputView.swift index 82135514..33888fd2 100644 --- a/AIProject/iCo/Features/ChatBot/View/ChatInputView.swift +++ b/AIProject/iCo/Features/ChatBot/View/ChatInputView.swift @@ -25,19 +25,20 @@ struct ChatInputView: View { Task { await viewModel.sendMessage(message: viewModel.searchText) } } label: { 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) }