Basic API ability

This commit is contained in:
Minecon724 2025-06-19 12:27:08 +02:00
commit 4d2dbe166c
Signed by untrusted user who does not match committer: m724
GPG key ID: A02E6E67AB961189
17 changed files with 291 additions and 43 deletions

2
.idea/.gitignore generated vendored
View file

@ -1,3 +1,5 @@
# Default ignored files # Default ignored files
/shelf/ /shelf/
/workspace.xml /workspace.xml
deploymentTargetSelector.xml

View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>

View file

@ -64,5 +64,6 @@ dependencies {
androidTestImplementation(libs.androidx.ui.test.junit4) androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest) debugImplementation(libs.androidx.ui.test.manifest)
debugImplementation(libs.logging.interceptor)
ksp(libs.hilt.compiler) ksp(libs.hilt.compiler)
} }

View file

@ -1,6 +1,7 @@
package eu.m724.chatapp.activity.chat package eu.m724.chatapp.activity.chat
import android.os.Bundle import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge 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.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -73,6 +75,7 @@ fun Content(
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val localSoftwareKeyboardController = LocalSoftwareKeyboardController.current val localSoftwareKeyboardController = LocalSoftwareKeyboardController.current
val context = LocalContext.current
var composerValue by remember { mutableStateOf("") } var composerValue by remember { mutableStateOf("") }
val composerFocusRequester = remember { FocusRequester() } val composerFocusRequester = remember { FocusRequester() }
@ -81,8 +84,12 @@ fun Content(
LaunchedEffect(uiState.requestInProgress) { // TODO probably not the best way LaunchedEffect(uiState.requestInProgress) { // TODO probably not the best way
if (!uiState.requestInProgress) { if (!uiState.requestInProgress) {
composerValue = "" if (uiState.requestLastError == null) {
composerFocusRequester.requestFocus() // TODO maybe make toggleable? or smart? 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 { } else {
if (!uiState.messageHistory.isEmpty()) { if (!uiState.messageHistory.isEmpty()) {
lazyListState.animateScrollToItem(uiState.messageHistory.size) lazyListState.animateScrollToItem(uiState.messageHistory.size)
@ -168,7 +175,7 @@ fun ChatTopAppBar(
) { ) {
AnimatedChangingText( AnimatedChangingText(
text = title, text = title,
) ) // TODO fade
} }
} }
) )
@ -176,9 +183,9 @@ fun ChatTopAppBar(
@Composable @Composable
fun MessageExchange( fun MessageExchange(
modifier: Modifier = Modifier,
isComposing: Boolean, isComposing: Boolean,
composerValue: String, composerValue: String,
modifier: Modifier = Modifier,
onComposerValueChange: (String) -> Unit = {}, onComposerValueChange: (String) -> Unit = {},
composerFocusRequester: FocusRequester = FocusRequester(), composerFocusRequester: FocusRequester = FocusRequester(),
responseValue: String = "", responseValue: String = "",

View file

@ -1,10 +1,30 @@
package eu.m724.chatapp.activity.chat package eu.m724.chatapp.activity.chat
data class ChatActivityUiState( data class ChatActivityUiState(
/**
* The title of the current chat
*/
val chatTitle: String? = null, val chatTitle: String? = null,
/**
* Whether a request is in progress (a response is streaming)
*/
val requestInProgress: Boolean = false, val requestInProgress: Boolean = false,
/**
* The response right now, updates when streaming
*/
val currentMessageResponse: String = "", val currentMessageResponse: String = "",
val messageHistory: List<ChatMessageExchange> = listOf()
/**
* All the messages of this chat
*/
val messageHistory: List<ChatMessageExchange> = listOf(),
/**
* Error, if any, of the last request
*/
val requestLastError: String? = null
) )
data class ChatMessageExchange( data class ChatMessageExchange(

View file

@ -4,7 +4,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import eu.m724.chatapp.api.AiApiService 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -19,13 +20,9 @@ class ChatActivityViewModel @Inject constructor(
private val _uiState = MutableStateFlow(ChatActivityUiState()) private val _uiState = MutableStateFlow(ChatActivityUiState())
val uiState: StateFlow<ChatActivityUiState> = _uiState.asStateFlow() val uiState: StateFlow<ChatActivityUiState> = _uiState.asStateFlow()
val responses = arrayOf( private val messages = mutableListOf<ChatMessage>()
"Hello right back at you! How can I help you today?",
"I'm sorry, but I can't assist with that."
)
fun sendMessage(message: String) { fun sendMessage(message: String) {
_uiState.update { _uiState.update {
it.copy( it.copy(
requestInProgress = true, requestInProgress = true,
@ -39,26 +36,44 @@ class ChatActivityViewModel @Inject constructor(
} }
} }
messages.add(ChatMessage(
role = ChatMessage.Role.USER,
content = message
))
viewModelScope.launch { viewModelScope.launch {
val response = responses.random() val response = aiApiService.chatComplete(ChatCompletionRequest(
val targetResponseParts = response.split(" ") model = "free-model",
messages = messages,
for (part in targetResponseParts) { temperature = 1.0f,
delay(50) maxTokens = 128,
frequencyPenalty = 0.0f,
presencePenalty = 0.0f
))
if (!response.isSuccessful || response.body() == null) {
_uiState.update { _uiState.update {
it.copy( 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 { _uiState.update {
it.copy( it.copy(
requestInProgress = false, requestInProgress = false,
messageHistory = it.messageHistory.plus( messageHistory = it.messageHistory.plus(
ChatMessageExchange(message, response) ChatMessageExchange(message, choice.message.content)
) )
) )
} }

View file

@ -59,7 +59,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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.math.RoundingMode
import java.text.DecimalFormat import java.text.DecimalFormat

View file

@ -1,6 +1,6 @@
package eu.m724.chatapp.activity.select package eu.m724.chatapp.activity.select
import eu.m724.chatapp.api.response.LanguageModel import eu.m724.chatapp.api.data.response.LanguageModel
data class SelectModelUiState( data class SelectModelUiState(
val models: List<LanguageModel> = listOf() val models: List<LanguageModel> = listOf()

View file

@ -1,11 +1,14 @@
package eu.m724.chatapp.api package eu.m724.chatapp.api
import com.google.gson.FieldNamingPolicy
import com.google.gson.GsonBuilder
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import eu.m724.chatapp.BuildConfig import eu.m724.chatapp.BuildConfig
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import javax.inject.Singleton import javax.inject.Singleton
@ -16,25 +19,33 @@ object AiApiNetworkModule {
@Provides @Provides
@Singleton @Singleton
fun provideOkHttpClient(): OkHttpClient { fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder() val interceptor = AiApiRequestInterceptor(
.addInterceptor { userAgent = BuildConfig.USER_AGENT,
it.proceed( apiEndpoint = BuildConfig.API_ENDPOINT,
it.request().newBuilder() apiKey = BuildConfig.API_KEY
.header("User-Agent", BuildConfig.USER_AGENT) )
.build()
) val builder = OkHttpClient.Builder()
// TODO add api key here .addInterceptor(interceptor)
}
.build() if (BuildConfig.DEBUG) {
builder.addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY))
}
return builder.build()
} }
@Provides @Provides
@Singleton @Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit { fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
val gson = GsonBuilder()
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) // snake_case
.create()
return Retrofit.Builder() return Retrofit.Builder()
.baseUrl(BuildConfig.API_ENDPOINT) .baseUrl(BuildConfig.API_ENDPOINT)
.client(okHttpClient) .client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create(gson))
.build() .build()
} }

View file

@ -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())
}
}

View file

@ -1,10 +1,17 @@
package eu.m724.chatapp.api 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.Response
import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.POST
interface AiApiService { interface AiApiService {
@GET("models?detailed=true") @GET("models?detailed=true")
suspend fun getModels(): Response<LanguageModelsResponse> suspend fun getModels(): Response<LanguageModelsResponse>
@POST("chat/completions")
suspend fun chatComplete(@Body body: ChatCompletionRequest): Response<ChatCompletionResponse>
} }

View file

@ -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
}
}

View file

@ -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<ChatMessage>,
/**
* 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" }
}
}

View file

@ -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<CompletionChoice>,
/**
* 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
)

View file

@ -1,4 +1,4 @@
package eu.m724.chatapp.api.response package eu.m724.chatapp.api.data.response
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName

View file

@ -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<LocalDateTime> {
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")
}
}

View file

@ -14,6 +14,7 @@ hilt = "2.56.2"
ksp = "2.1.21-2.0.2" ksp = "2.1.21-2.0.2"
retrofit = "3.0.0" retrofit = "3.0.0"
secrets = "2.0.1" secrets = "2.0.1"
loggingInterceptor = "4.12.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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" } hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }