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