From 755c93f9fd7133c6108892d544ad5ab5f88a8d45 Mon Sep 17 00:00:00 2001 From: Renato Date: Tue, 14 Mar 2023 09:09:09 -0300 Subject: [PATCH] feat: iOS sample integration --- .../co/yml/ychat/android/MainViewModel.kt | 1 - .../yml/ychat/android/ui/SendMessageLayout.kt | 1 - .../Features/Completion/CompletionView.swift | 33 +++++++++- .../Completion/Model/ChatMessage.swift | 1 + .../ViewModel/CompletionViewModel.swift | 25 ++++++- .../ychat/jvm/controller/YChatController.java | 8 +++ .../yml/ychat/jvm/services/YChatService.java | 8 +++ .../domain/mapper/ImageGenerationsMapper.kt | 1 - .../yml/ychat/domain/model/ImageGenerated.kt | 6 +- .../domain/model/ImageGenerationsParams.kt | 15 ++++- .../entrypoint/impl/ImageGenerationsImpl.kt | 5 +- .../co/yml/ychat/di/LibraryModuleTest.kt | 4 ++ .../mapper/ChatCompletionsMapperTest.kt | 41 ++++++++++++ .../mapper/ImageGenerationsMapperTest.kt | 30 +++++++++ .../model/ImageGenerationsParamsTest.kt | 20 ++++++ .../usecases/ImageGenerationsUseCaseTest.kt | 65 +++++++++++++++++++ 16 files changed, 246 insertions(+), 18 deletions(-) create mode 100644 ychat/src/commonTest/kotlin/co/yml/ychat/domain/mapper/ChatCompletionsMapperTest.kt create mode 100644 ychat/src/commonTest/kotlin/co/yml/ychat/domain/mapper/ImageGenerationsMapperTest.kt create mode 100644 ychat/src/commonTest/kotlin/co/yml/ychat/domain/model/ImageGenerationsParamsTest.kt create mode 100644 ychat/src/commonTest/kotlin/co/yml/ychat/domain/usecases/ImageGenerationsUseCaseTest.kt diff --git a/sample/android/src/main/java/co/yml/ychat/android/MainViewModel.kt b/sample/android/src/main/java/co/yml/ychat/android/MainViewModel.kt index 46cb6da..e37e388 100644 --- a/sample/android/src/main/java/co/yml/ychat/android/MainViewModel.kt +++ b/sample/android/src/main/java/co/yml/ychat/android/MainViewModel.kt @@ -23,7 +23,6 @@ class MainViewModel(private val chatGpt: YChat) : ViewModel() { private val imageGenerations by lazy { chatGpt.imageGenerations() - .setResults(2) } private val _items = mutableStateListOf() diff --git a/sample/android/src/main/java/co/yml/ychat/android/ui/SendMessageLayout.kt b/sample/android/src/main/java/co/yml/ychat/android/ui/SendMessageLayout.kt index e8673b0..8be8698 100644 --- a/sample/android/src/main/java/co/yml/ychat/android/ui/SendMessageLayout.kt +++ b/sample/android/src/main/java/co/yml/ychat/android/ui/SendMessageLayout.kt @@ -53,7 +53,6 @@ fun SendMessageLayout() { val viewModel = koinViewModel() val isLoading: Boolean by viewModel.isLoading.observeAsState(initial = false) - Row( modifier = Modifier .background(color = MaterialTheme.colors.background) diff --git a/sample/ios/YChatApp/Features/Completion/CompletionView.swift b/sample/ios/YChatApp/Features/Completion/CompletionView.swift index a4d44d0..108ee50 100644 --- a/sample/ios/YChatApp/Features/Completion/CompletionView.swift +++ b/sample/ios/YChatApp/Features/Completion/CompletionView.swift @@ -72,9 +72,14 @@ private extension CompletionView { } case .bot: HStack { - botChatBubble(message: chatMessage.message) - Spacer().frame(width: 60) - Spacer() + if let imageUrl = chatMessage.url { + botImageBubble(imageUrl) + Spacer() + } else { + botChatBubble(message: chatMessage.message) + Spacer().frame(width: 60) + Spacer() + } } case .loading: HStack { @@ -120,6 +125,28 @@ private extension CompletionView { .cornerRadius(16, corners: [.bottomLeft, .bottomLeft, .topRight]) } } + + @ViewBuilder + private func botImageBubble(_ url: String) -> some View { + HStack(alignment: .top, spacing: 4) { + Circle() + .fill(.green) + .frame(width: 40, height: 40) + .overlay { + Image(uiImage: Icon.bot.uiImage) + .renderingMode(.template) + .foregroundColor(.white) + } + ZStack { + AsyncImage(url: URL(string: url)) + .foregroundColor(.grayDark) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.grayLight) + .cornerRadius(16, corners: [.bottomLeft, .bottomLeft, .topRight]) + } + } @ViewBuilder private func sendMessageSection() -> some View { diff --git a/sample/ios/YChatApp/Features/Completion/Model/ChatMessage.swift b/sample/ios/YChatApp/Features/Completion/Model/ChatMessage.swift index 04306aa..1219bb2 100644 --- a/sample/ios/YChatApp/Features/Completion/Model/ChatMessage.swift +++ b/sample/ios/YChatApp/Features/Completion/Model/ChatMessage.swift @@ -12,6 +12,7 @@ struct ChatMessage: Identifiable, Equatable { let id: String var message: String = "" var type: MessageType = .human(error: false) + var url: String? enum MessageType: Equatable { case human(error: Bool), bot, loading diff --git a/sample/ios/YChatApp/Features/Completion/ViewModel/CompletionViewModel.swift b/sample/ios/YChatApp/Features/Completion/ViewModel/CompletionViewModel.swift index f13f22f..90ef52e 100644 --- a/sample/ios/YChatApp/Features/Completion/ViewModel/CompletionViewModel.swift +++ b/sample/ios/YChatApp/Features/Completion/ViewModel/CompletionViewModel.swift @@ -19,6 +19,10 @@ internal final class CompletionViewModel: ObservableObject { content: "You are a helpful assistant." ) + private var imageGenerations: ImageGenerations = + YChatCompanion.shared.create(apiKey: Config.apiKey) + .imageGenerations() + @Published var message: String = "" @@ -37,9 +41,15 @@ internal final class CompletionViewModel: ObservableObject { cleanLastMessage() addLoading() do { - let result = try await chatCompletions.execute(content: input)[0].content - removeLoading() - addAIMessage(message: result) + if input.contains("/image ") { + let result = try await imageGenerations.execute(prompt: input)[0].url + removeLoading() + addAIImage(url: result) + } else { + let result = try await chatCompletions.execute(content: input)[0].content + removeLoading() + addAIMessage(message: result) + } } catch { removeLoading() setError() @@ -64,6 +74,15 @@ internal final class CompletionViewModel: ObservableObject { ) chatMessageList.append(chatMessage) } + + private func addAIImage(url: String) { + let chatMessage = ChatMessage( + id: UUID().uuidString, + type: .bot, + url: url + ) + chatMessageList.append(chatMessage) + } private func addLoading() { let chatMessage = ChatMessage( diff --git a/sample/jvm/src/main/java/co/yml/ychat/jvm/controller/YChatController.java b/sample/jvm/src/main/java/co/yml/ychat/jvm/controller/YChatController.java index 4e1b1ac..6d6d2e1 100644 --- a/sample/jvm/src/main/java/co/yml/ychat/jvm/controller/YChatController.java +++ b/sample/jvm/src/main/java/co/yml/ychat/jvm/controller/YChatController.java @@ -32,6 +32,14 @@ public ResponseEntity chatCompletions( return ResponseEntity.ok(result); } + @GetMapping("generations") + public ResponseEntity imageGenerations( + @RequestParam(value = "prompt", defaultValue = Defaults.CHAT_COMPLETION_INPUT) String input + ) throws Exception { + String result = YChatService.getImageGenerationsAnswer(input); + return ResponseEntity.ok(result); + } + private static class Defaults { static final String COMPLETION_INPUT = "Say this is a test."; static final String CHAT_COMPLETION_INPUT = "Tell me one strength exercise"; diff --git a/sample/jvm/src/main/java/co/yml/ychat/jvm/services/YChatService.java b/sample/jvm/src/main/java/co/yml/ychat/jvm/services/YChatService.java index a6aaa34..3cb1f56 100644 --- a/sample/jvm/src/main/java/co/yml/ychat/jvm/services/YChatService.java +++ b/sample/jvm/src/main/java/co/yml/ychat/jvm/services/YChatService.java @@ -7,6 +7,7 @@ import org.springframework.stereotype.Service; import java.util.concurrent.CompletableFuture; import co.yml.ychat.YChat; +import co.yml.ychat.domain.model.ImageGenerated; @Service public class YChatService { @@ -35,6 +36,13 @@ public String getChatCompletionsAnswer(String input, String topic) throws Except return future.get().get(0).getContent(); } + public String getImageGenerationsAnswer(String prompt) throws Exception { + final CompletableFuture> future = new CompletableFuture<>(); + ychat.imageGenerations() + .execute(prompt, new CompletionCallbackResult<>(future)); + return future.get().get(0); + } + private static class CompletionCallbackResult implements YChat.Callback { private final CompletableFuture future; diff --git a/ychat/src/commonMain/kotlin/co/yml/ychat/domain/mapper/ImageGenerationsMapper.kt b/ychat/src/commonMain/kotlin/co/yml/ychat/domain/mapper/ImageGenerationsMapper.kt index 2db037a..9a280bc 100644 --- a/ychat/src/commonMain/kotlin/co/yml/ychat/domain/mapper/ImageGenerationsMapper.kt +++ b/ychat/src/commonMain/kotlin/co/yml/ychat/domain/mapper/ImageGenerationsMapper.kt @@ -11,7 +11,6 @@ internal fun ImageGenerationsDto.toImageGenerated(): List { } } - internal fun ImageGenerationsParams.toImageGenerationsParamsDto(): ImageGenerationsParamsDto { return ImageGenerationsParamsDto( prompt = this.prompt, diff --git a/ychat/src/commonMain/kotlin/co/yml/ychat/domain/model/ImageGenerated.kt b/ychat/src/commonMain/kotlin/co/yml/ychat/domain/model/ImageGenerated.kt index 439d332..9e6ba6f 100644 --- a/ychat/src/commonMain/kotlin/co/yml/ychat/domain/model/ImageGenerated.kt +++ b/ychat/src/commonMain/kotlin/co/yml/ychat/domain/model/ImageGenerated.kt @@ -4,10 +4,8 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** - * Represents a message in a conversation, consisting of a [role] indicating the speaker - * (e.g., “system”, “user” or “assistant”), and the [content] of the message sent by the speaker. - * @property role The role of the speaker who sends the message. - * @property content The content of the message sent by the speaker. + * Represents a image generated by a prompt, consisting of a [url] generated by the system (AI). + * @property url The url of the image generated by the input provided. */ @Serializable data class ImageGenerated( diff --git a/ychat/src/commonMain/kotlin/co/yml/ychat/domain/model/ImageGenerationsParams.kt b/ychat/src/commonMain/kotlin/co/yml/ychat/domain/model/ImageGenerationsParams.kt index aee1239..47c91f2 100644 --- a/ychat/src/commonMain/kotlin/co/yml/ychat/domain/model/ImageGenerationsParams.kt +++ b/ychat/src/commonMain/kotlin/co/yml/ychat/domain/model/ImageGenerationsParams.kt @@ -1,8 +1,21 @@ package co.yml.ychat.domain.model +/** + * Parameters to configure the Image Generations API. + * + * @param prompt The prompt(s) to generate images for. + * + * @param results: Quantity of images to be generated. + * + * @param size: The size of the images generated (squared). Ex. 256x256, 512x512, 1024x1024 + * + * @param responseFormat: The format in which the generated images are returned. Must be one of url or b64_json. + * + * @param user: A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. + */ internal data class ImageGenerationsParams( var prompt: String = "", - var results: Int = 2, + var results: Int = 1, var size: String = "256x256", var responseFormat: String = "url", var user: String = "", diff --git a/ychat/src/commonMain/kotlin/co/yml/ychat/entrypoint/impl/ImageGenerationsImpl.kt b/ychat/src/commonMain/kotlin/co/yml/ychat/entrypoint/impl/ImageGenerationsImpl.kt index 5f0caf0..070fd4a 100644 --- a/ychat/src/commonMain/kotlin/co/yml/ychat/entrypoint/impl/ImageGenerationsImpl.kt +++ b/ychat/src/commonMain/kotlin/co/yml/ychat/entrypoint/impl/ImageGenerationsImpl.kt @@ -1,7 +1,6 @@ package co.yml.ychat.entrypoint.impl import co.yml.ychat.YChat -import co.yml.ychat.domain.model.ChatCompletionsParams import co.yml.ychat.domain.model.ImageGenerated import co.yml.ychat.domain.model.ImageGenerationsParams import co.yml.ychat.domain.usecases.ImageGenerationsUseCase @@ -43,10 +42,8 @@ internal class ImageGenerationsImpl( override fun execute(prompt: String, callback: YChat.Callback>) { scope.launch { kotlin.runCatching { execute(prompt) } - .onSuccess { it -> callback.onSuccess(it.map { it.url }) } // fix here + .onSuccess { callback.onSuccess(it.map { it.url }) } .onFailure { callback.onError(it) } } } - - } diff --git a/ychat/src/commonTest/kotlin/co/yml/ychat/di/LibraryModuleTest.kt b/ychat/src/commonTest/kotlin/co/yml/ychat/di/LibraryModuleTest.kt index fef41e9..214b693 100644 --- a/ychat/src/commonTest/kotlin/co/yml/ychat/di/LibraryModuleTest.kt +++ b/ychat/src/commonTest/kotlin/co/yml/ychat/di/LibraryModuleTest.kt @@ -6,8 +6,10 @@ import co.yml.ychat.data.storage.ChatLogStorage import co.yml.ychat.di.module.LibraryModule import co.yml.ychat.domain.usecases.ChatCompletionsUseCase import co.yml.ychat.domain.usecases.CompletionUseCase +import co.yml.ychat.domain.usecases.ImageGenerationsUseCase import co.yml.ychat.entrypoint.features.ChatCompletions import co.yml.ychat.entrypoint.features.Completion +import co.yml.ychat.entrypoint.features.ImageGenerations import io.ktor.client.HttpClient import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -39,5 +41,7 @@ class LibraryModuleTest : KoinTest { get() get() get() + get() + get() } } diff --git a/ychat/src/commonTest/kotlin/co/yml/ychat/domain/mapper/ChatCompletionsMapperTest.kt b/ychat/src/commonTest/kotlin/co/yml/ychat/domain/mapper/ChatCompletionsMapperTest.kt new file mode 100644 index 0000000..5b86c8c --- /dev/null +++ b/ychat/src/commonTest/kotlin/co/yml/ychat/domain/mapper/ChatCompletionsMapperTest.kt @@ -0,0 +1,41 @@ +package co.yml.ychat.domain.mapper + +import co.yml.ychat.data.dto.ChatCompletionsChoiceDto +import co.yml.ychat.data.dto.ChatCompletionsDto +import co.yml.ychat.data.dto.ChatMessageDto +import co.yml.ychat.data.dto.UsageDto +import co.yml.ychat.domain.model.ChatCompletionsParams +import co.yml.ychat.domain.model.ChatMessage +import kotlin.test.Test +import kotlin.test.assertEquals + +class ChatCompletionsMapperTest { + + @Test + fun `on convert ChatCompletionsDto to ChatMessages`() { + val listOfChatMessages = listOf(ChatMessage("user", "message 1"), ChatMessage("user", "message 2")) + val chatCompletionsDto = ChatCompletionsDto( + choices = listOf( + ChatCompletionsChoiceDto(1, ChatMessageDto("user", "message 1"), null), + ChatCompletionsChoiceDto(1, ChatMessageDto("user", "message 2"), null) + ), + id = "1", + model = "", + usage = UsageDto(1, 1, 1) + ) + assertEquals(listOfChatMessages, chatCompletionsDto.toChatMessages()) + } + + + @Test + fun `on convert ChatCompletionsParams to ChatCompletionParamsDto`() { + val messages = arrayListOf(ChatMessage("user", "message 1"), ChatMessage("user", "message 2")) + val chatCompletionsParams = ChatCompletionsParams(messages) + assertEquals(messages.map { ChatMessageDto(it.role, it.content) }, chatCompletionsParams.toChatCompletionParamsDto().messages, "") + assertEquals("gpt-3.5-turbo", chatCompletionsParams.toChatCompletionParamsDto().model) + assertEquals(1, chatCompletionsParams.toChatCompletionParamsDto().maxResults) + assertEquals(4096, chatCompletionsParams.toChatCompletionParamsDto().maxTokens) + assertEquals(1.0, chatCompletionsParams.toChatCompletionParamsDto().temperature) + assertEquals(1.0, chatCompletionsParams.toChatCompletionParamsDto().topP) + } +} diff --git a/ychat/src/commonTest/kotlin/co/yml/ychat/domain/mapper/ImageGenerationsMapperTest.kt b/ychat/src/commonTest/kotlin/co/yml/ychat/domain/mapper/ImageGenerationsMapperTest.kt new file mode 100644 index 0000000..2f949ca --- /dev/null +++ b/ychat/src/commonTest/kotlin/co/yml/ychat/domain/mapper/ImageGenerationsMapperTest.kt @@ -0,0 +1,30 @@ +package co.yml.ychat.domain.mapper + +import co.yml.ychat.data.dto.ImageGenerationsDto +import co.yml.ychat.domain.model.ImageGenerated +import co.yml.ychat.domain.model.ImageGenerationsParams +import kotlin.test.Test +import kotlin.test.assertEquals + +class ImageGenerationsMapperTest { + + @Test + fun `on convert ImageGenerationsDto to ImageGenerated`() { + val listOfImageGenerated = listOf(ImageGenerated("http://url1.test"), ImageGenerated("http://url2.test")) + val imageGenerationsDto = ImageGenerationsDto( + created = 12345, + data = listOfImageGenerated + ) + assertEquals(listOfImageGenerated, imageGenerationsDto.toImageGenerated()) + } + + @Test + fun `on convert ImageGenerationsParams to ImageGenerationsDto`() { + val imageGenerationsParams = ImageGenerationsParams(prompt = "/image test") + assertEquals("/image test", imageGenerationsParams.toImageGenerationsParamsDto().prompt) + assertEquals("url", imageGenerationsParams.toImageGenerationsParamsDto().responseFormat) + assertEquals("256x256", imageGenerationsParams.toImageGenerationsParamsDto().size) + assertEquals("", imageGenerationsParams.toImageGenerationsParamsDto().user) + assertEquals(1, imageGenerationsParams.toImageGenerationsParamsDto().results) + } +} diff --git a/ychat/src/commonTest/kotlin/co/yml/ychat/domain/model/ImageGenerationsParamsTest.kt b/ychat/src/commonTest/kotlin/co/yml/ychat/domain/model/ImageGenerationsParamsTest.kt new file mode 100644 index 0000000..0c237cf --- /dev/null +++ b/ychat/src/commonTest/kotlin/co/yml/ychat/domain/model/ImageGenerationsParamsTest.kt @@ -0,0 +1,20 @@ +package co.yml.ychat.domain.model + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ImageGenerationsParamsTest { + + @Test + fun `on ChatCompletionsParams verify default values`() { + // arrange + val params = ImageGenerationsParams() + + // assert + assertEquals(true, params.prompt.isEmpty()) + assertEquals(1, params.results) + assertEquals("256x256", params.size) + assertEquals("url", params.responseFormat) + assertEquals("", params.user) + } +} diff --git a/ychat/src/commonTest/kotlin/co/yml/ychat/domain/usecases/ImageGenerationsUseCaseTest.kt b/ychat/src/commonTest/kotlin/co/yml/ychat/domain/usecases/ImageGenerationsUseCaseTest.kt new file mode 100644 index 0000000..c5e8628 --- /dev/null +++ b/ychat/src/commonTest/kotlin/co/yml/ychat/domain/usecases/ImageGenerationsUseCaseTest.kt @@ -0,0 +1,65 @@ +package co.yml.ychat.domain.usecases + +import co.yml.ychat.data.api.ChatGptApi +import co.yml.ychat.data.dto.ImageGenerationsDto +import co.yml.ychat.data.exception.ChatGptException +import co.yml.ychat.data.infrastructure.ApiResult +import co.yml.ychat.domain.model.ImageGenerated +import co.yml.ychat.domain.model.ImageGenerationsParams +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class ImageGenerationsUseCaseTest { + + private lateinit var imageGenerationsUseCase: ImageGenerationsUseCase + + private val chatGptApiMock = mockk() + + @BeforeTest + fun setup() { + imageGenerationsUseCase = ImageGenerationsUseCase(chatGptApiMock) + } + + @Test + fun `on requestImageGenerations when request succeed then should return formatted result`() { + // arrange + val prompt = "/image test" + val imageGenerationsDto = buildImageGenerationsDto("https://image-generated.test") + val params = ImageGenerationsParams(prompt = prompt) + val apiResult = ApiResult(body = imageGenerationsDto) + coEvery { chatGptApiMock.imageGenerations(any()) } returns apiResult + + // act + val result = runBlocking { imageGenerationsUseCase.requestImageGenerations(params) } + + // assert + assertEquals("https://image-generated.test", result.last().url) + } + + @Test + fun `on requestChatCompletions when not request succeed then should throw an exception`() { + // arrange + val prompt = "/image test" + val params = ImageGenerationsParams(prompt = prompt) + val apiResult = ApiResult(exception = ChatGptException()) + coEvery { chatGptApiMock.imageGenerations(any()) } returns apiResult + + // act + val result = + runCatching { runBlocking { imageGenerationsUseCase.requestImageGenerations(params) } } + + // assert + assertEquals(true, result.exceptionOrNull() is ChatGptException) + } + + private fun buildImageGenerationsDto(url: String): ImageGenerationsDto { + return ImageGenerationsDto( + created = 12345, + data = listOf(ImageGenerated(url)) + ) + } +}