-
Swift UI와 Gemini API를 활용한 Chat Bot: 채팅 구현 (2/2)공부/iOS 2024. 3. 19. 18:19
지난 포스트에서 이어서 작성하겠습니다. (코드 전문 포함)
목차
- 말풍선 모양 만들기
- 질문을 위한 입력 필드 만들기
- 채팅 말풍선 생성하기
- 채팅 메세지 리스트 생성하기
- 코드 전문
말풍선 모양 만들기
사용자(흰색)가 질문을 하면 gemini(노랑)가 답변하는 형태로 대화가 진행됩니다.
이를 위해
1. 우선, 채팅 말풍선 Shape를 만들어줍니다.
좌측하단, 우측하단, 우측상단을 둥글게 만들어 줍니다.
struct ChatBubbleShape: Shape { func path(in rect: CGRect) -> Path { let path = UIBezierPath( roundedRect: rect, byRoundingCorners: [.bottomLeft, .topRight, .bottomRight], cornerRadii: CGSize(width: 16, height: 16) ) return Path(path.cgPath) } }
2. 말풍선 모양의 ChatBox를 구현합니다.
좌측에는 프로필 사진이, 우측에는 메세지가 오도록 그려줍니다.
텍스트 길이가 짧은 경우에도 화면에 꽉 차게 만들어 주기 위해 텍스트의 프레임의 크기를 .infinity로 설정해줘야합니다.
HStack(alignment: .top) { Image(systemName: "person.fill") .resizable() .frame(width: 24, height: 24) .foregroundColor(.white) .padding(.top, 8) Text(message) .frame(maxWidth: .infinity, alignment: .leading) .padding() .background(.white) .foregroundColor(.black) .clipShape(ChatBubbleShape()) }
3. 텍스트가 없는 경우 ProgressView를 표시합니다
서버로 요청을 받아올 때 까지 기다리는 상황이 어색하지 않도록 텍스트 대신에 ProgressView를 보여줍니다.
또한, 사용자와 모델의 메세지에 맞게 결과를 보여줄 수 있도록 합니다.
import SwiftUI struct ChatBox: View { let message: String let profileImage: String let boxColor : Color var body: some View { HStack(alignment: .top) { Image(systemName: profileImage) .resizable() .frame(width: 24, height: 24) .foregroundColor(boxColor) .padding(.top, 8) if message.isEmpty { ProgressView() .frame(maxWidth: .infinity, alignment: .leading) .padding() .background(boxColor) .tint(.black) .clipShape(ChatBubbleShape()) } else { Text(message) .frame(maxWidth: .infinity, alignment: .leading) .padding() .background(boxColor) .foregroundColor(.black) .clipShape(ChatBubbleShape()) } } } }
입력을 받기 위해선 다음 3가지가 필요합니다.
- 이미지 피커: 이미지 데이터 첨부
- 텍스트 필드: 텍스트 데이터 입력
- 전송 버튼: 서버에 질문 요청하기
이것들을 HStack으로 만들어보겠습니다.
이미지 선택 기능
1. Image Picker
우선 입력 토큰 개수의 제한을 위해 사용자가 입력할 수 있는 이미지를 최대 3개로 설정해야 합니다.
피커를 선택하면 사용자의 "사진"에 접근해 이미지를 선택할 수 있습니다.
이렇게 이미지를 선택해서 "Done"을 누르게 되면, photoPickerItems 배열에 이미지 데이터가 추가됩니다.
2. Image Preview
질문을 하기 전까지는 선택된 이미지를 미리볼 수 있도록 Preview를 보여줍니다.
저장된 PhotosPickerItem 타입의 데이터를 옵셔널 캐스팅을 통해 Data 타입으로 변환해줍니다.
변환된 selectedPhotoData는 횡스크롤 프리뷰를 통해 보여지게 됩니다.
3. 이미지 선택 기능 코드 확인
@State private var textInput = "" @State private var chatService = ChatService() @State private var photoPickerItems = [PhotosPickerItem]() // 선택된 이미지 목록 @State private var selectedPhotoData = [Data]() // 이미지 프리뷰에 보여줄 사진 @FocusState private var isFocused: Bool // 키보드를 해제하기 위함 //MARK: - Image Picker HStack { PhotosPicker(selection: $photoPickerItems, maxSelectionCount: 3, matching: .images) { Image(systemName: "photo.stack.fill") .frame(width: 40, height: 25) } .onChange(of: photoPickerItems) { Task { selectedPhotoData.removeAll() for item in photoPickerItems { if let imageData = try await item.loadTransferable(type: Data.self) { selectedPhotoData.append(imageData) } } } } } //MARK: - Image Preview if selectedPhotoData.count > 0 { ScrollView(.horizontal) { LazyHStack(spacing: 10, content: { ForEach(0..<selectedPhotoData.count, id: \.self) { index in Image(uiImage: UIImage(data: selectedPhotoData[index])!) .resizable() .scaledToFit() .frame(height: 50) .clipShape(RoundedRectangle(cornerRadius: 5)) } }) } .frame(height: 50) }
입력 필드 완성하기
위에서 만든 이미지 피커와 함께 텍스트 필드를 추가해줍니다.
만약 질문을 요청하여 답변을 기다리는 중이라면, 전송 버튼 대신에 ProgressView를 보여줍니다.
질문은 chatService.sendMessage를 호출해 요청하게 되며, 다음 메세지를 입력하기 위해 사용된 텍스트, 이미지, 데이터를 삭제합니다.
//MARK: - Input Field HStack { // 이미지 피커를 추가 TextField("메세지를 입력해주세요...", text: $textInput) .textFieldStyle(.roundedBorder) .foregroundStyle(.black) .focused($isFocused) .disabled(chatService.loadingResponse) if chatService.loadingResponse { //MARK: - Loading indicator ProgressView() .tint(.white) .frame(width: 30) } else { //MARK: - Send Button Button(action: sendMessage, label: { Image(systemName: "paperplane.fill") .padding(.horizontal) }) .frame(width: 30) .disabled(textInput.isEmpty) } } //MARK: - Fetch Response private func sendMessage() { Task { await chatService.sendMessage(message: textInput, imageData: selectedPhotoData) textInput.removeAll() selectedPhotoData.removeAll() photoPickerItems.removeAll() } }
채팅 말풍선 생성하기
sendMessage를 통해 불러온 채팅메세지를 화면에 보여주어야겠죠?
이를 위해 ViewBuilder로 채팅메세지 View를 생성해줍니다.
메세지는 이미지가 있는 경우(멀티모달)과 아닌 경우로 구분해줍니다. 이미지 데이터가 존재하는 경우에는 위의 이미지 피커에서 했던 것 처럼 이미지를 보여줍니다. 차이점이 있다면 이미지 한장이 화면에 꽉 차게 하기 위해 scaledToFit() 대신에 scaledToFill()를 사용했습니다.
메세지는 사용자와 모델에 따라 구분을 위해 사진과 색상을 다르게 보여주었습니다.
//MARK: - Chat Message View @ViewBuilder private func chatMessageView(_ message: ChatMessage) -> some View { //MARK: - Chat image display if let images = message.images, !images.isEmpty { ScrollView(.horizontal) { LazyHStack(spacing: 10, content: { ForEach(0..<images.count, id: \.self) { index in Image(uiImage: UIImage(data: images[index])!) .resizable() .scaledToFill() .frame(height: 150) .clipShape(RoundedRectangle(cornerRadius: 5)) .containerRelativeFrame(.horizontal) } }) .scrollTargetLayout() } .frame(height: 150) } //MARK: - Chat Message Box ChatBox( message: message.message, profileImage: message.role == .model ? "moon.stars.fill" : "person.fill", boxColor: message.role == .model ? .yellow : .white ) }
채팅 메세지 리스트 생성하기
채팅 리스트를 만들기 위해 ScrollView를 만들어줍니다.
메세지마다 chatMessageView를 호출하여 채팅 메세지를 생성해주고, 가장 마지막에 생성된 메세지로 스크롤을 해줍니다.
//MARK: - Chat Message List ScrollViewReader(content: { proxy in ScrollView { ForEach(chatService.messages) { chatMessage in //MARK: - Chat Message View chatMessageView(chatMessage) } } .onChange(of: chatService.messages) { guard let recentMessage = chatService.messages.last else { return } DispatchQueue.main.async { withAnimation { proxy.scrollTo(recentMessage.id, anchor: .bottom) } } } })
코드 전문
더보기Chat.swift
import Foundation enum ChatRole { case user case model } struct ChatMessage: Identifiable, Equatable { let id = UUID().uuidString var role: ChatRole var message: String var images: [Data]? }
ChatBox.swift
import SwiftUI struct ChatBox: View { let message: String let profileImage: String let boxColor : Color var body: some View { HStack(alignment: .top) { Image(systemName: profileImage) .resizable() .frame(width: 24, height: 24) .foregroundColor(boxColor) .padding(.top, 8) if message.isEmpty { ProgressView() .frame(maxWidth: .infinity, alignment: .leading) .padding() .background(boxColor) .tint(.black) .clipShape(ChatBubbleShape()) } else { Text(message) .frame(maxWidth: .infinity, alignment: .leading) .padding() .background(boxColor) .foregroundColor(.black) .clipShape(ChatBubbleShape()) } } } } struct ChatBubbleShape: Shape { func path(in rect: CGRect) -> Path { let path = UIBezierPath( roundedRect: rect, byRoundingCorners: [.bottomLeft, .topRight, .bottomRight], cornerRadii: CGSize(width: 16, height: 16) ) return Path(path.cgPath) } }
ChatService.swift
import SwiftUI import GoogleGenerativeAI @Observable class ChatService { private(set) var messages = [ChatMessage]() private(set) var history = [ModelContent]() private(set) var loadingResponse = false func sendMessage(message: String, imageData: [Data]) async { loadingResponse = true //MARK: - 유저 메세지, AI 메세지를 리스트에 추가 messages.append(.init(role: .user, message: message, images: imageData)) messages.append(.init(role: .model, message: "", images: nil)) do { if imageData.isEmpty { //MARK: - Multi-turn 대화 with streaming let config = GenerationConfig(maxOutputTokens: 100) let chatModel = GenerativeModel(name: "gemini-pro", apiKey: APIKey.default, generationConfig: config) let chat = chatModel.startChat(history: history) let responseStream = chat.sendMessageStream(message) for try await chunk in responseStream { guard let text = chunk.text else { return } let lastChatMessageIndex = messages.count - 1 messages[lastChatMessageIndex].message += text } history.append(.init(role: "user", message)) history.append(.init(role: "model", parts: messages.last?.message ?? "")) loadingResponse = false } else { //MARK: - 이미지 입력으로 텍스트 생성(multimodal) let chatModel = GenerativeModel(name: "gemini-pro-vision", apiKey: APIKey.default) var images = [PartsRepresentable]() for data in imageData { // 이미지 최대 크기를 4MB로 제한하기 위해 Data compression 진행 if let compressedData = UIImage(data: data)?.jpegData(compressionQuality: 0.1) { images.append(ModelContent.Part.jpeg(compressedData)) } } // Request and stream the response let contentStream = chatModel.generateContentStream(message, images) for try await chunk in contentStream { guard let text = chunk.text else { return } let lastChatMessageIndex = messages.count - 1 messages[lastChatMessageIndex].message += text } loadingResponse = false } } catch { loadingResponse = false messages.removeLast() messages.append(.init(role: .model, message: "다시 시도해주세요.")) print(error.localizedDescription) } } }
ContentView.swift
import SwiftUI import PhotosUI struct ContentView: View { @State private var textInput = "" @State private var chatService = ChatService() @State private var photoPickerItems = [PhotosPickerItem]() @State private var selectedPhotoData = [Data]() @FocusState private var isFocused: Bool var body: some View { VStack(alignment: .leading) { //MARK: - Logo Image("gemini_logo") .resizable() .scaledToFit() .frame(height: 36) .padding(.bottom, 8) .onTapGesture { chatService = ChatService() print("reset") } //MARK: - Chat Message List ScrollViewReader(content: { proxy in ScrollView { ForEach(chatService.messages) { chatMessage in //MARK: - Chat Message View chatMessageView(chatMessage) } } .onChange(of: chatService.messages) { guard let recentMessage = chatService.messages.last else { return } DispatchQueue.main.async { withAnimation { proxy.scrollTo(recentMessage.id, anchor: .bottom) } } } }) //MARK: - Image Preview if selectedPhotoData.count > 0 { ScrollView(.horizontal) { LazyHStack(spacing: 10, content: { ForEach(0..<selectedPhotoData.count, id: \.self) { index in Image(uiImage: UIImage(data: selectedPhotoData[index])!) .resizable() .scaledToFit() .frame(height: 50) .clipShape(RoundedRectangle(cornerRadius: 5)) } }) } .frame(height: 50) } //MARK: - Input Field HStack { PhotosPicker(selection: $photoPickerItems, maxSelectionCount: 3, matching: .images) { Image(systemName: "photo.stack.fill") .frame(width: 40, height: 25) } .onChange(of: photoPickerItems) { Task { selectedPhotoData.removeAll() for item in photoPickerItems { if let imageData = try await item.loadTransferable(type: Data.self) { selectedPhotoData.append(imageData) } } } } TextField("메세지를 입력해주세요...", text: $textInput) .textFieldStyle(.roundedBorder) .foregroundStyle(.black) .focused($isFocused) .disabled(chatService.loadingResponse) if chatService.loadingResponse { //MARK: - Loading indicator ProgressView() .tint(.white) .frame(width: 30) } else { //MARK: - Send Button Button(action: sendMessage, label: { Image(systemName: "paperplane.fill") .padding(.horizontal) }) .frame(width: 30) .disabled(textInput.isEmpty) } } } .foregroundStyle(.white) .padding() .background { //MARK: - Background ZStack { Color.black } .ignoresSafeArea() .onTapGesture { isFocused = false } } } //MARK: - Chat Message View @ViewBuilder private func chatMessageView(_ message: ChatMessage) -> some View { //MARK: - Chat image display if let images = message.images, !images.isEmpty { ScrollView(.horizontal) { LazyHStack(spacing: 10, content: { ForEach(0..<images.count, id: \.self) { index in Image(uiImage: UIImage(data: images[index])!) .resizable() .scaledToFill() .frame(height: 150) .clipShape(RoundedRectangle(cornerRadius: 5)) .containerRelativeFrame(.horizontal) } }) .scrollTargetLayout() } .frame(height: 150) } //MARK: - Chat Message Box ChatBox( message: message.message, profileImage: message.role == .model ? "moon.stars.fill" : "person.fill", boxColor: message.role == .model ? .yellow : .white ) } //MARK: - Fetch Response private func sendMessage() { Task { await chatService.sendMessage(message: textInput, imageData: selectedPhotoData) textInput.removeAll() selectedPhotoData.removeAll() photoPickerItems.removeAll() } } } #Preview { ContentView() }
'공부 > iOS' 카테고리의 다른 글
Swift UI와 Gemini API를 활용한 Chat Bot: 기능 구현 (1/2) (0) 2024.03.13 [SwiftUI] 1. TCA를 왜 도입하는걸까? (MVVM의 운명은?) (0) 2024.02.27 [iOS] status code가 200일 때 빈 응답이 들어오는 경우 (0) 2024.02.17 [iOS]텍스트필드가 등장할 때 타임아웃 뜨는 버그 해결법 (2) 2024.01.17 키체인을 활용해 jwt Token 안전하게 저장하기 (1) 2024.01.16