공부/iOS

Swift UI와 Gemini API를 활용한 Chat Bot: 채팅 구현 (2/2)

안토니1 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()
}