diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index ff2abe0..864e7e8 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -11,6 +11,7 @@ object Versions { const val COMPOSE_ACTIVITY = "1.6.1" const val COMPOSE_NAVIGATION = "2.5.3" const val COMPOSE_LIVEDATA = "1.3.3" + const val COIL = "2.2.2" const val KTOR = "2.2.2" const val KOIN = "3.2.0" const val MATERIAL_DESIGN = "1.6.1" @@ -52,6 +53,7 @@ object Dependencies { const val COMPOSE_ACTIVITY = "androidx.activity:activity-compose:${Versions.COMPOSE_ACTIVITY}" const val COMPOSE_NAVIGATION = "androidx.navigation:navigation-compose:${Versions.COMPOSE_NAVIGATION}" const val COMPOSE_LIVEDATA = "androidx.compose.runtime:runtime-livedata:${Versions.COMPOSE_LIVEDATA}" + const val COIL = "io.coil-kt:coil-compose:${Versions.COIL}" } object Test { diff --git a/sample/android/build.gradle.kts b/sample/android/build.gradle.kts index 76481a2..6e73bef 100644 --- a/sample/android/build.gradle.kts +++ b/sample/android/build.gradle.kts @@ -53,6 +53,7 @@ dependencies { implementation(Dependencies.UI.COMPOSE_ACTIVITY) implementation(Dependencies.UI.COMPOSE_NAVIGATION) implementation(Dependencies.UI.COMPOSE_LIVEDATA) + implementation(Dependencies.UI.COIL) implementation(Dependencies.DI.KOIN_CORE) implementation(Dependencies.DI.KOIN_ANDROID) implementation(Dependencies.DI.KOIN_COMPOSE) 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 90daeda..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 @@ -6,6 +6,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.yml.ychat.YChat +import co.yml.ychat.YChat.Callback import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -20,6 +21,10 @@ class MainViewModel(private val chatGpt: YChat) : ViewModel() { ) } + private val imageGenerations by lazy { + chatGpt.imageGenerations() + } + private val _items = mutableStateListOf() val items = _items @@ -30,7 +35,7 @@ class MainViewModel(private val chatGpt: YChat) : ViewModel() { private var typingItem = mutableStateOf(MessageItem(message = typingTxt.value, isOut = false)) private fun setLoading(isLoading: Boolean) { - _isLoading.value = isLoading + _isLoading.postValue(isLoading) } fun onSendMessage(message: String, typingStr: String) { @@ -41,6 +46,22 @@ class MainViewModel(private val chatGpt: YChat) : ViewModel() { } } + fun onImageRequest(prompt: String, typingStr: String) { + updateTypingMessage(typingStr) + viewModelScope.launch { + showTypingAnimation(prompt) + imageGenerations.execute(prompt, object : Callback> { + override fun onSuccess(result: List) { + showImages(result) + } + + override fun onError(throwable: Throwable) { + writeResponse(ERROR) + } + }) + } + } + private suspend fun showTypingAnimation(message: String) { items.add(MessageItem(message = message, isOut = true)) delay((1000..2000).random().toLong()) @@ -54,6 +75,14 @@ class MainViewModel(private val chatGpt: YChat) : ViewModel() { setLoading(false) } + private fun showImages(result: List) { + items.remove(items[items.lastIndex]) + result.forEach { + items.add(MessageItem(message = IMAGE, isOut = false, url = it)) + } + setLoading(false) + } + private suspend fun requestCompletion(message: String): String { return try { chatCompletions.execute(message).last().content @@ -77,5 +106,6 @@ class MainViewModel(private val chatGpt: YChat) : ViewModel() { companion object { private const val ERROR = "Error" private const val MAX_TOKENS = 1024 + private const val IMAGE = "image" } } \ No newline at end of file diff --git a/sample/android/src/main/java/co/yml/ychat/android/MessageItem.kt b/sample/android/src/main/java/co/yml/ychat/android/MessageItem.kt index cd1cf5d..80f9ff9 100644 --- a/sample/android/src/main/java/co/yml/ychat/android/MessageItem.kt +++ b/sample/android/src/main/java/co/yml/ychat/android/MessageItem.kt @@ -3,4 +3,5 @@ package co.yml.ychat.android data class MessageItem( val message: String, val isOut: Boolean, + val url: String? = null ) \ No newline at end of file diff --git a/sample/android/src/main/java/co/yml/ychat/android/ui/ChatLayout.kt b/sample/android/src/main/java/co/yml/ychat/android/ui/ChatLayout.kt index d93c86c..ee43cca 100644 --- a/sample/android/src/main/java/co/yml/ychat/android/ui/ChatLayout.kt +++ b/sample/android/src/main/java/co/yml/ychat/android/ui/ChatLayout.kt @@ -77,9 +77,15 @@ fun ChatLayout( .padding(spaceMedium), ) { items(messages) { message -> - MessageItemLayout( - messageText = message.message, isOut = message.isOut - ) + message.url?.let { + ImageItemLayout( + messageText = message.url, isOut = message.isOut + ) + } ?: run { + MessageItemLayout( + messageText = message.message, isOut = message.isOut + ) + } } coroutineScope.launch { listState.animateScrollToItem(messages.size) diff --git a/sample/android/src/main/java/co/yml/ychat/android/ui/ImageItemLayout.kt b/sample/android/src/main/java/co/yml/ychat/android/ui/ImageItemLayout.kt new file mode 100644 index 0000000..9b382ff --- /dev/null +++ b/sample/android/src/main/java/co/yml/ychat/android/ui/ImageItemLayout.kt @@ -0,0 +1,87 @@ +package co.yml.ychat.android.ui + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import co.yml.ychat.android.R +import co.yml.ychat.android.ui.Dimensions.default +import co.yml.ychat.android.ui.Dimensions.robotMessageIconSize +import co.yml.ychat.android.ui.Dimensions.robotMessagePaddingSize +import co.yml.ychat.android.ui.Dimensions.spaceExtraSmall +import co.yml.ychat.android.ui.Dimensions.spaceMedium +import co.yml.ychat.android.ui.Dimensions.spaceSmall +import coil.compose.AsyncImage + +@Composable +fun ImageItemLayout( + messageText: String, + isOut: Boolean +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = if (isOut) Alignment.End else Alignment.Start + ) { + Row( + modifier = Modifier.padding(top = spaceMedium), + verticalAlignment = Alignment.Bottom + ) { + if (isOut.not()) { + Image( + painterResource(R.drawable.ic_robot), + contentDescription = "", + modifier = Modifier + .width(robotMessageIconSize) + .height(robotMessageIconSize) + .clip(shape = CircleShape) + .background(colorResource(id = R.color.softGreen)) + .padding(robotMessagePaddingSize), + ) + Spacer(modifier = Modifier.padding(spaceExtraSmall)) + } + Box( + modifier = Modifier + .clip( + shape = RoundedCornerShape( + topStart = spaceMedium, + topEnd = spaceMedium, + bottomEnd = if (isOut) default else spaceMedium, + bottomStart = if (isOut) spaceMedium else default + ) + ) + .background(if (isOut) colorResource(id = R.color.softBlue) else colorResource(id = R.color.opaqueWhite)) + .padding(spaceSmall) + ) { + AsyncImage( + modifier = Modifier.clip(RoundedCornerShape(8.dp)), + model = messageText, + contentDescription = messageText, + placeholder = painterResource(R.drawable.ic_robot), + ) + } + } + } +} + +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +fun PreviewImageItemLayout() { + MessageItemLayout(messageText = "Message", isOut = false) +} \ No newline at end of file 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 f0d26ee..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 @@ -52,6 +52,7 @@ fun SendMessageLayout() { val scope = rememberCoroutineScope() val viewModel = koinViewModel() val isLoading: Boolean by viewModel.isLoading.observeAsState(initial = false) + Row( modifier = Modifier .background(color = MaterialTheme.colors.background) @@ -91,7 +92,11 @@ fun SendMessageLayout() { .background(if (textFieldState.isNotEmpty() && isLoading.not()) colorResource(id = R.color.softBlue) else colorResource(id = R.color.opaqueWhite)), onClick = { scope.launch { - viewModel.onSendMessage(textFieldState, typingString) + if (textFieldState.startsWith("/image ")) { + viewModel.onImageRequest(textFieldState, typingString) + } else { + viewModel.onSendMessage(textFieldState, typingString) + } textFieldState = "" } }, 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/README.md b/sample/jvm/README.md index e870312..fbe18d4 100644 --- a/sample/jvm/README.md +++ b/sample/jvm/README.md @@ -42,4 +42,18 @@ This endpoint generates text based on the provided prompt and a specified topic. ##### Example: -`GET http://localhost:8080/api/ychat/chat-completions?input="Tell me an exercise plan"&topic=fitness` \ No newline at end of file +`GET http://localhost:8080/api/ychat/chat-completions?input="Tell me an exercise plan"&topic=fitness` + +### Image Generations Endpoint + +This endpoint generates images based on the provided prompt. + +##### Endpoint: http://localhost:[port_number]/api/ychat/generations + +##### Parameters: + +- `prompt`: The prompt for generating images. + +##### Example: + +`GET http://localhost:8080/api/ychat/generations?prompt="ocean" \ No newline at end of file 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..05a3290 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,9 +32,18 @@ public ResponseEntity chatCompletions( return ResponseEntity.ok(result); } + @GetMapping("generations") + public ResponseEntity imageGenerations( + @RequestParam(value = "prompt", defaultValue = Defaults.IMAGE_GENERATION_TOPIC) 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"; static final String CHAT_COMPLETION_TOPIC = "fitness"; + static final String IMAGE_GENERATION_TOPIC = "ocean"; } } 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..6fa70fb 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 @@ -35,6 +35,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/YChat.kt b/ychat/src/commonMain/kotlin/co/yml/ychat/YChat.kt index 4bca17e..c6e2286 100644 --- a/ychat/src/commonMain/kotlin/co/yml/ychat/YChat.kt +++ b/ychat/src/commonMain/kotlin/co/yml/ychat/YChat.kt @@ -2,6 +2,7 @@ package co.yml.ychat import co.yml.ychat.entrypoint.features.ChatCompletions import co.yml.ychat.entrypoint.features.Completion +import co.yml.ychat.entrypoint.features.ImageGenerations import co.yml.ychat.entrypoint.impl.YChatImpl import kotlin.jvm.JvmStatic import kotlin.jvm.Volatile @@ -77,6 +78,21 @@ interface YChat { */ fun chatCompletions(): ChatCompletions + /** + * The image generations api is used to generate images based on a prompt. You input some text as a + * prompt, and the model will generate one or more images. + * + * You can configure the parameters of the completion before executing it. Example: + * ``` + * val result = YChat.create(apiKey).imageGenerations() + * .setResults(2) + * .setSize(1024x1024) + * .set... + * .execute("/image ocean") + * ``` + */ + fun imageGenerations(): ImageGenerations + /** * Callback is an interface used for handling the results of an operation. * It provides two methods, `onSuccess` and `onError`, for handling the success diff --git a/ychat/src/commonMain/kotlin/co/yml/ychat/data/api/ChatGptApi.kt b/ychat/src/commonMain/kotlin/co/yml/ychat/data/api/ChatGptApi.kt index 4996e4c..a7e3a9f 100644 --- a/ychat/src/commonMain/kotlin/co/yml/ychat/data/api/ChatGptApi.kt +++ b/ychat/src/commonMain/kotlin/co/yml/ychat/data/api/ChatGptApi.kt @@ -4,6 +4,8 @@ import co.yml.ychat.data.dto.ChatCompletionParamsDto import co.yml.ychat.data.dto.ChatCompletionsDto import co.yml.ychat.data.dto.CompletionDto import co.yml.ychat.data.dto.CompletionParamsDto +import co.yml.ychat.data.dto.ImageGenerationsDto +import co.yml.ychat.data.dto.ImageGenerationsParamsDto import co.yml.ychat.data.infrastructure.ApiResult internal interface ChatGptApi { @@ -11,4 +13,6 @@ internal interface ChatGptApi { suspend fun completion(paramsDto: CompletionParamsDto): ApiResult suspend fun chatCompletions(paramsDto: ChatCompletionParamsDto): ApiResult + + suspend fun imageGenerations(paramsDto: ImageGenerationsParamsDto): ApiResult } diff --git a/ychat/src/commonMain/kotlin/co/yml/ychat/data/api/impl/ChatGptApiImpl.kt b/ychat/src/commonMain/kotlin/co/yml/ychat/data/api/impl/ChatGptApiImpl.kt index 1135ed1..791c8c2 100644 --- a/ychat/src/commonMain/kotlin/co/yml/ychat/data/api/impl/ChatGptApiImpl.kt +++ b/ychat/src/commonMain/kotlin/co/yml/ychat/data/api/impl/ChatGptApiImpl.kt @@ -5,6 +5,8 @@ import co.yml.ychat.data.dto.ChatCompletionParamsDto import co.yml.ychat.data.dto.ChatCompletionsDto import co.yml.ychat.data.dto.CompletionDto import co.yml.ychat.data.dto.CompletionParamsDto +import co.yml.ychat.data.dto.ImageGenerationsDto +import co.yml.ychat.data.dto.ImageGenerationsParamsDto import co.yml.ychat.data.infrastructure.ApiExecutor import co.yml.ychat.data.infrastructure.ApiResult import io.ktor.http.HttpMethod @@ -26,4 +28,12 @@ internal class ChatGptApiImpl(private val apiExecutor: ApiExecutor) : ChatGptApi .setBody(paramsDto) .execute() } + + override suspend fun imageGenerations(paramsDto: ImageGenerationsParamsDto): ApiResult { + return apiExecutor + .setEndpoint("v1/images/generations") + .setHttpMethod(HttpMethod.Post) + .setBody(paramsDto) + .execute() + } } diff --git a/ychat/src/commonMain/kotlin/co/yml/ychat/data/dto/ImageGenerationsDto.kt b/ychat/src/commonMain/kotlin/co/yml/ychat/data/dto/ImageGenerationsDto.kt new file mode 100644 index 0000000..7f2d2e2 --- /dev/null +++ b/ychat/src/commonMain/kotlin/co/yml/ychat/data/dto/ImageGenerationsDto.kt @@ -0,0 +1,13 @@ +package co.yml.ychat.data.dto + +import co.yml.ychat.domain.model.ImageGeneratedDto +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class ImageGenerationsDto( + @SerialName("created") + val created: Long, + @SerialName("data") + val data: List, +) diff --git a/ychat/src/commonMain/kotlin/co/yml/ychat/data/dto/ImageGenerationsParamsDto.kt b/ychat/src/commonMain/kotlin/co/yml/ychat/data/dto/ImageGenerationsParamsDto.kt new file mode 100644 index 0000000..62ab7c3 --- /dev/null +++ b/ychat/src/commonMain/kotlin/co/yml/ychat/data/dto/ImageGenerationsParamsDto.kt @@ -0,0 +1,18 @@ +package co.yml.ychat.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class ImageGenerationsParamsDto( + @SerialName("prompt") + val prompt: String, + @SerialName("n") + val results: Int, + @SerialName("size") + val size: String, + @SerialName("response_format") + val responseFormat: String, + @SerialName("user") + val user: String, +) diff --git a/ychat/src/commonMain/kotlin/co/yml/ychat/di/module/LibraryModule.kt b/ychat/src/commonMain/kotlin/co/yml/ychat/di/module/LibraryModule.kt index 4106d79..7f1b5b7 100644 --- a/ychat/src/commonMain/kotlin/co/yml/ychat/di/module/LibraryModule.kt +++ b/ychat/src/commonMain/kotlin/co/yml/ychat/di/module/LibraryModule.kt @@ -7,10 +7,13 @@ import co.yml.ychat.data.storage.ChatLogStorage import co.yml.ychat.di.provider.NetworkProvider 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 co.yml.ychat.entrypoint.impl.ChatCompletionsImpl import co.yml.ychat.entrypoint.impl.CompletionImpl +import co.yml.ychat.entrypoint.impl.ImageGenerationsImpl import kotlinx.coroutines.Dispatchers import org.koin.core.module.Module import org.koin.dsl.module @@ -23,11 +26,13 @@ internal class LibraryModule(private val apiKey: String) { private val entrypointModule = module { factory { CompletionImpl(Dispatchers.Default, get()) } factory { ChatCompletionsImpl(Dispatchers.Default, get()) } + factory { ImageGenerationsImpl(Dispatchers.Default, get()) } } private val domainModule = module { factory { CompletionUseCase(get(), get()) } factory { ChatCompletionsUseCase(get()) } + factory { ImageGenerationsUseCase(get()) } } private val dataModule = module { 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 new file mode 100644 index 0000000..ea109b7 --- /dev/null +++ b/ychat/src/commonMain/kotlin/co/yml/ychat/domain/mapper/ImageGenerationsMapper.kt @@ -0,0 +1,21 @@ +package co.yml.ychat.domain.mapper + +import co.yml.ychat.data.dto.ImageGenerationsDto +import co.yml.ychat.data.dto.ImageGenerationsParamsDto +import co.yml.ychat.domain.model.ImageGenerationsParams + +internal fun ImageGenerationsDto.toImageGenerated(): List { + return this.data.map { + it.url + } +} + +internal fun ImageGenerationsParams.toImageGenerationsParamsDto(): ImageGenerationsParamsDto { + return ImageGenerationsParamsDto( + prompt = this.prompt, + results = this.results, + size = this.size, + responseFormat = this.responseFormat, + user = this.user + ) +} diff --git a/ychat/src/commonMain/kotlin/co/yml/ychat/domain/model/ImageGeneratedDto.kt b/ychat/src/commonMain/kotlin/co/yml/ychat/domain/model/ImageGeneratedDto.kt new file mode 100644 index 0000000..055f950 --- /dev/null +++ b/ychat/src/commonMain/kotlin/co/yml/ychat/domain/model/ImageGeneratedDto.kt @@ -0,0 +1,14 @@ +package co.yml.ychat.domain.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * 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 +internal data class ImageGeneratedDto( + @SerialName("url") + val url: String, +) 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 new file mode 100644 index 0000000..47c91f2 --- /dev/null +++ b/ychat/src/commonMain/kotlin/co/yml/ychat/domain/model/ImageGenerationsParams.kt @@ -0,0 +1,22 @@ +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 = 1, + var size: String = "256x256", + var responseFormat: String = "url", + var user: String = "", +) diff --git a/ychat/src/commonMain/kotlin/co/yml/ychat/domain/usecases/ImageGenerationsUseCase.kt b/ychat/src/commonMain/kotlin/co/yml/ychat/domain/usecases/ImageGenerationsUseCase.kt new file mode 100644 index 0000000..f0aa77f --- /dev/null +++ b/ychat/src/commonMain/kotlin/co/yml/ychat/domain/usecases/ImageGenerationsUseCase.kt @@ -0,0 +1,15 @@ +package co.yml.ychat.domain.usecases + +import co.yml.ychat.data.api.ChatGptApi +import co.yml.ychat.domain.mapper.toImageGenerated +import co.yml.ychat.domain.mapper.toImageGenerationsParamsDto +import co.yml.ychat.domain.model.ImageGenerationsParams + +internal data class ImageGenerationsUseCase(private val chatGptApi: ChatGptApi) { + + suspend fun requestImageGenerations(params: ImageGenerationsParams): List { + val requestDto = params.toImageGenerationsParamsDto() + val response = chatGptApi.imageGenerations(requestDto) + return response.getBodyOrThrow().toImageGenerated() + } +} diff --git a/ychat/src/commonMain/kotlin/co/yml/ychat/entrypoint/features/ImageGenerations.kt b/ychat/src/commonMain/kotlin/co/yml/ychat/entrypoint/features/ImageGenerations.kt new file mode 100644 index 0000000..864041e --- /dev/null +++ b/ychat/src/commonMain/kotlin/co/yml/ychat/entrypoint/features/ImageGenerations.kt @@ -0,0 +1,19 @@ +package co.yml.ychat.entrypoint.features + +import co.yml.ychat.YChat +import co.yml.ychat.data.exception.ChatGptException +import kotlin.coroutines.cancellation.CancellationException + +interface ImageGenerations { + + fun setResults(results: Int): ImageGenerations + + fun setSize(size: String): ImageGenerations + + fun setResponseFormat(responseFormat: String): ImageGenerations + + @Throws(CancellationException::class, ChatGptException::class) + suspend fun execute(prompt: String): List + + fun execute(prompt: String, callback: YChat.Callback>) +} 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 new file mode 100644 index 0000000..1f03284 --- /dev/null +++ b/ychat/src/commonMain/kotlin/co/yml/ychat/entrypoint/impl/ImageGenerationsImpl.kt @@ -0,0 +1,48 @@ +package co.yml.ychat.entrypoint.impl + +import co.yml.ychat.YChat +import co.yml.ychat.domain.model.ImageGenerationsParams +import co.yml.ychat.domain.usecases.ImageGenerationsUseCase +import co.yml.ychat.entrypoint.features.ImageGenerations +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +internal class ImageGenerationsImpl( + private val dispatcher: CoroutineDispatcher, + private val imageGenerationsUseCase: ImageGenerationsUseCase, +) : ImageGenerations { + + private val scope by lazy { CoroutineScope(SupervisorJob() + dispatcher) } + + private var params: ImageGenerationsParams = ImageGenerationsParams() + + override fun setResults(results: Int): ImageGenerations { + params.results = results + return this + } + + override fun setSize(size: String): ImageGenerations { + params.size = size + return this + } + + override fun setResponseFormat(responseFormat: String): ImageGenerations { + params.responseFormat = responseFormat + return this + } + + override suspend fun execute(prompt: String): List { + params.prompt = prompt + return imageGenerationsUseCase.requestImageGenerations(params) + } + + override fun execute(prompt: String, callback: YChat.Callback>) { + scope.launch { + runCatching { execute(prompt) } + .onSuccess { callback.onSuccess(it) } + .onFailure { callback.onError(it) } + } + } +} diff --git a/ychat/src/commonMain/kotlin/co/yml/ychat/entrypoint/impl/YChatImpl.kt b/ychat/src/commonMain/kotlin/co/yml/ychat/entrypoint/impl/YChatImpl.kt index 324a95f..c29f71e 100644 --- a/ychat/src/commonMain/kotlin/co/yml/ychat/entrypoint/impl/YChatImpl.kt +++ b/ychat/src/commonMain/kotlin/co/yml/ychat/entrypoint/impl/YChatImpl.kt @@ -4,6 +4,7 @@ import co.yml.ychat.YChat import co.yml.ychat.di.module.LibraryModule import co.yml.ychat.entrypoint.features.ChatCompletions import co.yml.ychat.entrypoint.features.Completion +import co.yml.ychat.entrypoint.features.ImageGenerations import org.koin.core.KoinApplication internal class YChatImpl(apiKey: String) : YChat { @@ -22,4 +23,8 @@ internal class YChatImpl(apiKey: String) : YChat { override fun chatCompletions(): ChatCompletions { return koinApp.koin.get() } + + override fun imageGenerations(): ImageGenerations { + return koinApp.koin.get() + } } 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..49f6582 --- /dev/null +++ b/ychat/src/commonTest/kotlin/co/yml/ychat/domain/mapper/ChatCompletionsMapperTest.kt @@ -0,0 +1,40 @@ +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..0ce66ff --- /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.ImageGeneratedDto +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 listOfImageGeneratedDto = listOf(ImageGeneratedDto("http://url1.test"), ImageGeneratedDto("http://url2.test")) + val imageGenerationsDto = ImageGenerationsDto( + created = 12345, + data = listOfImageGeneratedDto + ) + assertEquals(listOfImageGeneratedDto.map { it.url }, 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..ad1ffe7 --- /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.ImageGeneratedDto +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()) + } + + @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(ImageGeneratedDto(url)) + ) + } +} diff --git a/ychat/src/commonTest/kotlin/co/yml/ychat/entrypoint/YChatTest.kt b/ychat/src/commonTest/kotlin/co/yml/ychat/entrypoint/YChatTest.kt index c0e264c..0b5be31 100644 --- a/ychat/src/commonTest/kotlin/co/yml/ychat/entrypoint/YChatTest.kt +++ b/ychat/src/commonTest/kotlin/co/yml/ychat/entrypoint/YChatTest.kt @@ -73,6 +73,26 @@ class YChatTest { assertEquals("This in indeed a test", result) } + @Test + fun `on imageGenerations execute method should return result successfully`() { + // arrange + val imageUrl = "https://testlink.com/image-test.jpg" + val imageGenerationsSuccessResult = MockStorage.imageGenerationsSuccessResult(imageUrl) + mockHttpEngine(imageGenerationsSuccessResult) + + // act + val result = runBlocking { + yChat.imageGenerations() + .setResults(1) + .setSize("256x256") + .setResponseFormat("url") + .execute("/image ocean") + } + + // assert + assertEquals("https://testlink.com/image-test.jpg", result.first()) + } + private fun mockHttpEngine(result: String) { val httpEngine = MockEngine { respond( diff --git a/ychat/src/commonTest/kotlin/infrastructure/MockStorage.kt b/ychat/src/commonTest/kotlin/infrastructure/MockStorage.kt index 8c81b1c..95d843f 100644 --- a/ychat/src/commonTest/kotlin/infrastructure/MockStorage.kt +++ b/ychat/src/commonTest/kotlin/infrastructure/MockStorage.kt @@ -13,4 +13,7 @@ object MockStorage { "\"usage\":{\"prompt_tokens\":13,\"completion_tokens\":12,\"total_tokens\":25}," + "\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"$text\"}," + "\"finish_reason\":\"stop\",\"index\":0}]}" + + fun imageGenerationsSuccessResult(text: String) = + "{\"created\":1678805561,\"data\":[{\"url\":\"$text\"}]}" }