ABOUT ME

공부하려고 찾았던 자료를 같이 공유하고자 합니다. 피드백, 감사인사 모두 환영입니다.

Today
Yesterday
Total
  • 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()
    }

     

     

Designed by Tistory.