From 4d2dbe166ca9c52bae1a251232deb6c9ed6b773d Mon Sep 17 00:00:00 2001 From: Minecon724 Date: Thu, 19 Jun 2025 12:27:08 +0200 Subject: [PATCH] Basic API ability --- .idea/.gitignore | 2 + .idea/deploymentTargetSelector.xml | 10 --- app/build.gradle.kts | 1 + .../chatapp/activity/chat/ChatActivity.kt | 15 +++- .../activity/chat/ChatActivityUiState.kt | 22 ++++- .../activity/chat/ChatActivityViewModel.kt | 41 +++++++--- .../activity/select/SelectModelActivity.kt | 2 +- .../activity/select/SelectModelUiState.kt | 2 +- .../eu/m724/chatapp/api/AiApiNetworkModule.kt | 33 +++++--- .../chatapp/api/AiApiRequestInterceptor.kt | 21 +++++ .../java/eu/m724/chatapp/api/AiApiService.kt | 9 ++- .../eu/m724/chatapp/api/data/ChatMessage.kt | 12 +++ .../api/data/request/ChatCompletionRequest.kt | 57 +++++++++++++ .../data/response/ChatCompletionResponse.kt | 80 +++++++++++++++++++ .../response/LanguageModelsResponse.kt | 2 +- .../EpochSecondToLocalDateTimeDeserializer.kt | 23 ++++++ gradle/libs.versions.toml | 2 + 17 files changed, 291 insertions(+), 43 deletions(-) delete mode 100644 .idea/deploymentTargetSelector.xml create mode 100644 app/src/main/java/eu/m724/chatapp/api/AiApiRequestInterceptor.kt create mode 100644 app/src/main/java/eu/m724/chatapp/api/data/ChatMessage.kt create mode 100644 app/src/main/java/eu/m724/chatapp/api/data/request/ChatCompletionRequest.kt create mode 100644 app/src/main/java/eu/m724/chatapp/api/data/response/ChatCompletionResponse.kt rename app/src/main/java/eu/m724/chatapp/api/{ => data}/response/LanguageModelsResponse.kt (96%) create mode 100644 app/src/main/java/eu/m724/chatapp/api/serialize/EpochSecondToLocalDateTimeDeserializer.kt diff --git a/.idea/.gitignore b/.idea/.gitignore index 26d3352..cfeeb2f 100644 --- a/.idea/.gitignore +++ b/.idea/.gitignore @@ -1,3 +1,5 @@ # Default ignored files /shelf/ /workspace.xml + +deploymentTargetSelector.xml \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml deleted file mode 100644 index b268ef3..0000000 --- a/.idea/deploymentTargetSelector.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f555100..1a2a535 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -64,5 +64,6 @@ dependencies { androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) + debugImplementation(libs.logging.interceptor) ksp(libs.hilt.compiler) } \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivity.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivity.kt index e3aa4c8..81f1f81 100644 --- a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivity.kt +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivity.kt @@ -1,6 +1,7 @@ package eu.m724.chatapp.activity.chat import android.os.Bundle +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -45,6 +46,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -73,6 +75,7 @@ fun Content( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val localSoftwareKeyboardController = LocalSoftwareKeyboardController.current + val context = LocalContext.current var composerValue by remember { mutableStateOf("") } val composerFocusRequester = remember { FocusRequester() } @@ -81,8 +84,12 @@ fun Content( LaunchedEffect(uiState.requestInProgress) { // TODO probably not the best way if (!uiState.requestInProgress) { - composerValue = "" - composerFocusRequester.requestFocus() // TODO maybe make toggleable? or smart? + if (uiState.requestLastError == null) { + composerValue = "" + composerFocusRequester.requestFocus() // TODO maybe make toggleable? or smart? + } else { + Toast.makeText(context, uiState.requestLastError, Toast.LENGTH_SHORT).show() // TODO better way of showing this + } } else { if (!uiState.messageHistory.isEmpty()) { lazyListState.animateScrollToItem(uiState.messageHistory.size) @@ -168,7 +175,7 @@ fun ChatTopAppBar( ) { AnimatedChangingText( text = title, - ) + ) // TODO fade } } ) @@ -176,9 +183,9 @@ fun ChatTopAppBar( @Composable fun MessageExchange( - modifier: Modifier = Modifier, isComposing: Boolean, composerValue: String, + modifier: Modifier = Modifier, onComposerValueChange: (String) -> Unit = {}, composerFocusRequester: FocusRequester = FocusRequester(), responseValue: String = "", diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityUiState.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityUiState.kt index 9921b6b..f1170b8 100644 --- a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityUiState.kt +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityUiState.kt @@ -1,10 +1,30 @@ package eu.m724.chatapp.activity.chat data class ChatActivityUiState( + /** + * The title of the current chat + */ val chatTitle: String? = null, + + /** + * Whether a request is in progress (a response is streaming) + */ val requestInProgress: Boolean = false, + + /** + * The response right now, updates when streaming + */ val currentMessageResponse: String = "", - val messageHistory: List = listOf() + + /** + * All the messages of this chat + */ + val messageHistory: List = listOf(), + + /** + * Error, if any, of the last request + */ + val requestLastError: String? = null ) data class ChatMessageExchange( diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityViewModel.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityViewModel.kt index 8288219..c440d75 100644 --- a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityViewModel.kt +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityViewModel.kt @@ -4,7 +4,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import eu.m724.chatapp.api.AiApiService -import kotlinx.coroutines.delay +import eu.m724.chatapp.api.data.ChatMessage +import eu.m724.chatapp.api.data.request.ChatCompletionRequest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -19,13 +20,9 @@ class ChatActivityViewModel @Inject constructor( private val _uiState = MutableStateFlow(ChatActivityUiState()) val uiState: StateFlow = _uiState.asStateFlow() - val responses = arrayOf( - "Hello right back at you! How can I help you today?", - "I'm sorry, but I can't assist with that." - ) + private val messages = mutableListOf() fun sendMessage(message: String) { - _uiState.update { it.copy( requestInProgress = true, @@ -39,26 +36,44 @@ class ChatActivityViewModel @Inject constructor( } } + messages.add(ChatMessage( + role = ChatMessage.Role.USER, + content = message + )) + viewModelScope.launch { - val response = responses.random() - val targetResponseParts = response.split(" ") - - for (part in targetResponseParts) { - delay(50) + val response = aiApiService.chatComplete(ChatCompletionRequest( + model = "free-model", + messages = messages, + temperature = 1.0f, + maxTokens = 128, + frequencyPenalty = 0.0f, + presencePenalty = 0.0f + )) + if (!response.isSuccessful || response.body() == null) { _uiState.update { it.copy( - currentMessageResponse = it.currentMessageResponse.trim() + " $part" + requestInProgress = false, + requestLastError = response.code().toString() ) } + + // TODO launch toast or something + return@launch } + val completion = response.body()!! + val choice = completion.choices[0] + + messages.add(choice.message) + _uiState.update { it.copy( requestInProgress = false, messageHistory = it.messageHistory.plus( - ChatMessageExchange(message, response) + ChatMessageExchange(message, choice.message.content) ) ) } diff --git a/app/src/main/java/eu/m724/chatapp/activity/select/SelectModelActivity.kt b/app/src/main/java/eu/m724/chatapp/activity/select/SelectModelActivity.kt index 31fbc15..880f906 100644 --- a/app/src/main/java/eu/m724/chatapp/activity/select/SelectModelActivity.kt +++ b/app/src/main/java/eu/m724/chatapp/activity/select/SelectModelActivity.kt @@ -59,7 +59,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import eu.m724.chatapp.api.response.LanguageModel +import eu.m724.chatapp.api.data.response.LanguageModel import java.math.RoundingMode import java.text.DecimalFormat diff --git a/app/src/main/java/eu/m724/chatapp/activity/select/SelectModelUiState.kt b/app/src/main/java/eu/m724/chatapp/activity/select/SelectModelUiState.kt index 24b31e4..cd8cdf6 100644 --- a/app/src/main/java/eu/m724/chatapp/activity/select/SelectModelUiState.kt +++ b/app/src/main/java/eu/m724/chatapp/activity/select/SelectModelUiState.kt @@ -1,6 +1,6 @@ package eu.m724.chatapp.activity.select -import eu.m724.chatapp.api.response.LanguageModel +import eu.m724.chatapp.api.data.response.LanguageModel data class SelectModelUiState( val models: List = listOf() diff --git a/app/src/main/java/eu/m724/chatapp/api/AiApiNetworkModule.kt b/app/src/main/java/eu/m724/chatapp/api/AiApiNetworkModule.kt index 49f9c11..708d09f 100644 --- a/app/src/main/java/eu/m724/chatapp/api/AiApiNetworkModule.kt +++ b/app/src/main/java/eu/m724/chatapp/api/AiApiNetworkModule.kt @@ -1,11 +1,14 @@ package eu.m724.chatapp.api +import com.google.gson.FieldNamingPolicy +import com.google.gson.GsonBuilder import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import eu.m724.chatapp.BuildConfig import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import javax.inject.Singleton @@ -16,25 +19,33 @@ object AiApiNetworkModule { @Provides @Singleton fun provideOkHttpClient(): OkHttpClient { - return OkHttpClient.Builder() - .addInterceptor { - it.proceed( - it.request().newBuilder() - .header("User-Agent", BuildConfig.USER_AGENT) - .build() - ) - // TODO add api key here - } - .build() + val interceptor = AiApiRequestInterceptor( + userAgent = BuildConfig.USER_AGENT, + apiEndpoint = BuildConfig.API_ENDPOINT, + apiKey = BuildConfig.API_KEY + ) + + val builder = OkHttpClient.Builder() + .addInterceptor(interceptor) + + if (BuildConfig.DEBUG) { + builder.addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) + } + + return builder.build() } @Provides @Singleton fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit { + val gson = GsonBuilder() + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) // snake_case + .create() + return Retrofit.Builder() .baseUrl(BuildConfig.API_ENDPOINT) .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create()) + .addConverterFactory(GsonConverterFactory.create(gson)) .build() } diff --git a/app/src/main/java/eu/m724/chatapp/api/AiApiRequestInterceptor.kt b/app/src/main/java/eu/m724/chatapp/api/AiApiRequestInterceptor.kt new file mode 100644 index 0000000..8056556 --- /dev/null +++ b/app/src/main/java/eu/m724/chatapp/api/AiApiRequestInterceptor.kt @@ -0,0 +1,21 @@ +package eu.m724.chatapp.api + +import okhttp3.Interceptor +import okhttp3.Response + +class AiApiRequestInterceptor( + private val userAgent: String, + private val apiEndpoint: String, + private val apiKey: String +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val builder = chain.request().newBuilder() + .header("User-Agent", userAgent) + + if (chain.request().url.toString().startsWith(apiEndpoint)) { + builder.header("Authorization", "Bearer $apiKey") + } + + return chain.proceed(builder.build()) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/api/AiApiService.kt b/app/src/main/java/eu/m724/chatapp/api/AiApiService.kt index 371ffc8..b4fb195 100644 --- a/app/src/main/java/eu/m724/chatapp/api/AiApiService.kt +++ b/app/src/main/java/eu/m724/chatapp/api/AiApiService.kt @@ -1,10 +1,17 @@ package eu.m724.chatapp.api -import eu.m724.chatapp.api.response.LanguageModelsResponse +import eu.m724.chatapp.api.data.request.ChatCompletionRequest +import eu.m724.chatapp.api.data.response.ChatCompletionResponse +import eu.m724.chatapp.api.data.response.LanguageModelsResponse import retrofit2.Response +import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.POST interface AiApiService { @GET("models?detailed=true") suspend fun getModels(): Response + + @POST("chat/completions") + suspend fun chatComplete(@Body body: ChatCompletionRequest): Response } \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/api/data/ChatMessage.kt b/app/src/main/java/eu/m724/chatapp/api/data/ChatMessage.kt new file mode 100644 index 0000000..492484f --- /dev/null +++ b/app/src/main/java/eu/m724/chatapp/api/data/ChatMessage.kt @@ -0,0 +1,12 @@ +package eu.m724.chatapp.api.data + +data class ChatMessage( + val role: Role, + val content: String +) { + enum class Role { + SYSTEM, + USER, + ASSISTANT + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/api/data/request/ChatCompletionRequest.kt b/app/src/main/java/eu/m724/chatapp/api/data/request/ChatCompletionRequest.kt new file mode 100644 index 0000000..85eb40f --- /dev/null +++ b/app/src/main/java/eu/m724/chatapp/api/data/request/ChatCompletionRequest.kt @@ -0,0 +1,57 @@ +package eu.m724.chatapp.api.data.request + +import eu.m724.chatapp.api.data.ChatMessage + +data class ChatCompletionRequest( + /** + * The model ID + */ + val model: String, + + /** + * The messages in the current chat. Usually a user message is the last one if making a request. + */ + val messages: List, + + /** + * Controls the "creativity" of the model. + * Read more: https://www.iguazio.com/glossary/llm-temperature/ + */ + val temperature: Float, + + /** + * The maximum amount of tokens to generate + */ + val maxTokens: Int, + + /** + * Controls the repetition of words in the generated text. + * Applies an incremental penalty, depending on how many times a token appears in the text. + * + * Read more: https://www.promptitude.io/glossary/frequency-penalty + * @see presencePenalty + */ + val frequencyPenalty: Float, + + /** + * Controls the repetition of words in the generated text. + * Applies a constant penalty, no matter how many times a token appears in the text. + * + * Read more: https://www.promptitude.io/glossary/frequency-penalty + * @see frequencyPenalty + */ + val presencePenalty: Float +) { + init { + require(temperature >= 0.0) { "temperature must be at least 0.0" } + require(temperature <= 2.0) { "temperature must be at most 2.0" } + + require(maxTokens >= 0) { "maxTokens must be at least 0. If you don't want a limit here, use Int.MAX_VALUE"} + + require(frequencyPenalty >= -2.0) { "frequencyPenalty must be at least -2.0" } + require(frequencyPenalty <= 2.0) { "frequencyPenalty must be at most 2.0" } + + require(presencePenalty >= -2.0) { "presencePenalty must be at least -2.0" } + require(presencePenalty <= 2.0) { "presencePenalty must be at most 2.0" } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/api/data/response/ChatCompletionResponse.kt b/app/src/main/java/eu/m724/chatapp/api/data/response/ChatCompletionResponse.kt new file mode 100644 index 0000000..ac66fb0 --- /dev/null +++ b/app/src/main/java/eu/m724/chatapp/api/data/response/ChatCompletionResponse.kt @@ -0,0 +1,80 @@ +package eu.m724.chatapp.api.data.response + +import com.google.gson.annotations.JsonAdapter +import com.google.gson.annotations.SerializedName +import eu.m724.chatapp.api.data.ChatMessage +import eu.m724.chatapp.api.serialize.EpochSecondToLocalDateTimeDeserializer +import java.time.LocalDateTime + +data class ChatCompletionResponse( + /** + * Request ID + */ + val id: String, + + /** + * Request time + */ + @SerializedName("created") + @JsonAdapter(EpochSecondToLocalDateTimeDeserializer::class) + val createdAt: LocalDateTime, + + /** + * Completion choices. Usually has only one element. + */ + val choices: List, + + /** + * The cost (in tokens) of this completion + */ + @SerializedName("usage") + val tokenUsage: CompletionTokenUsage +) + +data class CompletionChoice( + val index: Int, + + /** + * The generated message + */ + val message: ChatMessage, + + /** + * The reason why generating the response has stopped + */ + val finishReason: CompletionFinishReason +) + +enum class CompletionFinishReason { + /** + * The response has stopped, because the model said so + */ + STOP, + + /** + * The response has stopped, because it got too long + */ + LENGTH, + + /** + * The response has stopped, because the content got flagged + */ + CONTENT_FILTER +} + +data class CompletionTokenUsage( + /** + * The amount of tokens of the prompt + */ + val promptTokens: Int, + + /** + * The amount of tokens of the generated completion + */ + val completionTokens: Int, + + /** + * The total amount of tokens processed + */ + val totalTokens: Int +) \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/api/response/LanguageModelsResponse.kt b/app/src/main/java/eu/m724/chatapp/api/data/response/LanguageModelsResponse.kt similarity index 96% rename from app/src/main/java/eu/m724/chatapp/api/response/LanguageModelsResponse.kt rename to app/src/main/java/eu/m724/chatapp/api/data/response/LanguageModelsResponse.kt index df5ec14..1a35506 100644 --- a/app/src/main/java/eu/m724/chatapp/api/response/LanguageModelsResponse.kt +++ b/app/src/main/java/eu/m724/chatapp/api/data/response/LanguageModelsResponse.kt @@ -1,4 +1,4 @@ -package eu.m724.chatapp.api.response +package eu.m724.chatapp.api.data.response import com.google.gson.annotations.SerializedName diff --git a/app/src/main/java/eu/m724/chatapp/api/serialize/EpochSecondToLocalDateTimeDeserializer.kt b/app/src/main/java/eu/m724/chatapp/api/serialize/EpochSecondToLocalDateTimeDeserializer.kt new file mode 100644 index 0000000..ceafc3b --- /dev/null +++ b/app/src/main/java/eu/m724/chatapp/api/serialize/EpochSecondToLocalDateTimeDeserializer.kt @@ -0,0 +1,23 @@ +package eu.m724.chatapp.api.serialize + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import java.lang.reflect.Type +import java.time.LocalDateTime +import java.time.ZoneOffset + +class EpochSecondToLocalDateTimeDeserializer : JsonDeserializer { + override fun deserialize( + json: JsonElement?, + typeOfT: Type?, + context: JsonDeserializationContext? + ): LocalDateTime? { + json?.asLong?.let { timestamp -> + return LocalDateTime.ofEpochSecond(timestamp, 0, ZoneOffset.UTC) + } + + throw JsonParseException("Error deserializing LocalDateTime from $json") + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 24f0691..6c62f1f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ hilt = "2.56.2" ksp = "2.1.21-2.0.2" retrofit = "3.0.0" secrets = "2.0.1" +loggingInterceptor = "4.12.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -36,6 +37,7 @@ hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit"} +logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "loggingInterceptor" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }