This commit is contained in:
2026-07-29 16:21:59 +03:00
parent b83a40f4bd
commit 6485f4ec26
41 changed files with 797 additions and 224 deletions
@@ -1,31 +1,40 @@
package ru.shadowsparky.chat.backend.data
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerAuthProvider
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Single
import ru.shadowsparky.chat.backend.domain.ChatService
import ru.shadowsparky.chat.domain.Message
import ru.shadowsparky.http.domain.HttpClientFactory
import java.util.UUID
@Factory
@Single
class GigaChatService(
private val tokenManager: GigaChatTokenManager,
private val json: Json
private val httpClientFactory: HttpClientFactory
) : ChatService {
private val httpClient by lazy { createGigaChatHttpClient() }
private val httpClient by lazy {
httpClientFactory.create(
BearerAuthProvider(
refreshTokens = {
val newToken = tokenManager.forceRefreshToken()
BearerTokens(newToken, null)
},
loadTokens = {
BearerTokens(tokenManager.getToken(), null)
},
realm = null
)
)
}
override suspend fun getCompletion(userId: Long, messages: List<Message>): String {
val clientId = "vbox-$userId"
@@ -44,21 +53,6 @@ class GigaChatService(
?: error("unexpected response $response")
}
private fun createGigaChatHttpClient(): HttpClient {
return HttpClient {
install(ContentNegotiation) { json(json) }
install(Auth) {
bearer {
loadTokens { BearerTokens(tokenManager.getToken(), "") }
refreshTokens {
val newToken = tokenManager.forceRefreshToken()
BearerTokens(newToken, "")
}
}
}
}
}
@Serializable
private data class GigaChatRequest(
val model: String = "GigaChat-2",
@@ -1,13 +1,13 @@
package ru.shadowsparky.chat.backend.data
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.auth.providers.BearerAuthProvider
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.request.forms.FormDataContent
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.Parameters
import io.ktor.http.contentType
import kotlinx.coroutines.sync.Mutex
@@ -17,16 +17,35 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.koin.core.annotation.Single
import ru.shadowsparky.backend.data.EnvFetcher
import ru.shadowsparky.http.data.RefreshTokenRunner
import ru.shadowsparky.http.domain.BadRequestException
import ru.shadowsparky.http.domain.HttpClientFactory
import java.util.UUID
@Single
class GigaChatTokenManager(
private val authClient: HttpClient,
private val json: Json,
private val envFetcher: EnvFetcher
private val envFetcher: EnvFetcher,
private val httpClientFactory: HttpClientFactory,
private val refreshTokenRunner: RefreshTokenRunner
) {
private val scope: String = "GIGACHAT_API_PERS"
private val authClient by lazy {
httpClientFactory.create(
BearerAuthProvider(
refreshTokens = { null },
loadTokens = {
BearerTokens(
envFetcher.get("GIGA_CHAT_AUTH_TOKEN").ifEmpty { error("auth token not provided") },
null
)
},
realm = null
)
)
}
private val mutex = Mutex()
private var cachedToken: String? = null
@@ -34,14 +53,14 @@ class GigaChatTokenManager(
cachedToken ?: fetchNewToken()
}
suspend fun forceRefreshToken(): String = mutex.withLock {
fetchNewToken()
suspend fun forceRefreshToken(): String {
return refreshTokenRunner.runOrJoin {
BearerTokens(fetchNewToken(), null)
}?.accessToken ?: throw BadRequestException("Unable to refresh access token")
}
private suspend fun fetchNewToken(): String {
val auth = envFetcher.get("GIGA_CHAT_AUTH_TOKEN").ifEmpty { error("auth token not provided") }
val responseString: String = authClient.post("https://ngw.devices.sberbank.ru:9443/api/v2/oauth") {
header(HttpHeaders.Authorization, auth)
header("RqUID", UUID.randomUUID().toString())
contentType(ContentType.Application.FormUrlEncoded)
setBody(FormDataContent(Parameters.build {
@@ -3,6 +3,6 @@ package ru.shadowsparky.chat.backend.domain
import ru.shadowsparky.chat.domain.Message
interface MessageStorage {
suspend fun query(userId: Long, limit: Int): List<Message>
suspend fun query(userId: Long, afterId: Long?, limit: Int): List<Message>
suspend fun insert(userId: Long, message: Message): Long
}
@@ -10,7 +10,7 @@ class ProcessUserMessageUseCase(
private val chatService: ChatService
) {
suspend fun execute(userId: Long): Message {
val history = storage.query(userId, limit = 10).toList()
val history = storage.query(userId, null, limit = 10).toList()
val sortedHistory = history.sortedBy { it.timestamp }
val systemMessage = Message(
role = ChatRoles.SYSTEM,
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="chat_title">Чат</string>
<string name="chat_load_error">Не удалось загрузить сообщения</string>
<string name="chat_send_error">Не удалось отправить сообщение</string>
<string name="chat_retry_button">Повторить</string>
<string name="chat_input_placeholder">Сообщение...</string>
<string name="chat_send_button">Отправить</string>
<string name="chat_ok">ОК</string>
</resources>
@@ -0,0 +1,105 @@
package ru.shadowsparky.chat.presentation
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.value.MutableValue
import com.arkivanov.decompose.value.Value
import com.arkivanov.essenty.lifecycle.doOnDestroy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import ru.shadowsparky.chat.domain.ChatRepository
import ru.shadowsparky.chat.domain.ChatRoles
import ru.shadowsparky.chat.domain.Message
sealed interface ChatState {
object Loading : ChatState
data class Error(val message: String) : ChatState
data class Data(
val messages: List<Message>,
val isSending: Boolean = false,
val isSubsequentLoading: Boolean = false,
val sendError: String? = null
) : ChatState
}
class ChatComponent(
componentContext: ComponentContext,
private val repository: ChatRepository
) : ComponentContext by componentContext {
private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()).also { ctx ->
lifecycle.doOnDestroy { ctx.cancel() }
}
private val _state = MutableValue<ChatState>(ChatState.Loading)
val state: Value<ChatState> = _state
init {
loadInitialMessages()
}
fun loadInitialMessages() {
_state.value = ChatState.Loading
scope.launch {
try {
val dbList = repository.query(afterId = null)
_state.value = ChatState.Data(messages = dbList)
} catch (e: Exception) {
_state.value = ChatState.Error(e.message ?: e.toString())
}
}
}
fun loadMoreMessages() {
val currentState = _state.value as? ChatState.Data ?: return
if (currentState.isSubsequentLoading || currentState.messages.isEmpty()) return
val latestMessageId = currentState.messages.last().id ?: return
_state.value = currentState.copy(isSubsequentLoading = true)
scope.launch {
try {
val newMessages = repository.query(afterId = latestMessageId)
if (newMessages.isNotEmpty()) {
_state.value = currentState.copy(
messages = currentState.messages + newMessages,
isSubsequentLoading = false
)
} else {
_state.value = currentState.copy(isSubsequentLoading = false)
}
} catch (e: Exception) {
_state.value = currentState.copy(isSubsequentLoading = false)
}
}
}
fun sendMessage(content: String) {
val currentState = _state.value as? ChatState.Data ?: return
if (content.isBlank() || currentState.isSending) return
_state.value = currentState.copy(isSending = true, sendError = null)
scope.launch {
try {
repository.send(Message(role = ChatRoles.USER, content = content, timestamp = 0L))
val latestMessageId = currentState.messages.lastOrNull()?.id
val freshMessages = repository.query(afterId = latestMessageId)
_state.value = currentState.copy(
messages = currentState.messages + freshMessages,
isSending = false
)
} catch (e: Exception) {
_state.value = currentState.copy(
isSending = false,
sendError = e.message ?: e.toString()
)
}
}
}
fun clearSendError() {
val currentState = _state.value as? ChatState.Data ?: return
_state.value = currentState.copy(sendError = null)
}
}
@@ -0,0 +1,222 @@
package ru.shadowsparky.chat.presentation
import androiddev.feature.chat.chat_client.generated.resources.Res
import androiddev.feature.chat.chat_client.generated.resources.chat_input_placeholder
import androiddev.feature.chat.chat_client.generated.resources.chat_load_error
import androiddev.feature.chat.chat_client.generated.resources.chat_ok
import androiddev.feature.chat.chat_client.generated.resources.chat_retry_button
import androiddev.feature.chat.chat_client.generated.resources.chat_send_button
import androiddev.feature.chat.chat_client.generated.resources.chat_title
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Snackbar
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import org.jetbrains.compose.resources.stringResource
import ru.shadowsparky.chat.domain.ChatRoles
import ru.shadowsparky.chat.domain.Message
import ru.shadowsparky.ui.AppScaffold
import ru.shadowsparky.ui.BlurState
import ru.shadowsparky.ui.applyBlurEffect
import ru.shadowsparky.ui.applyBlurSource
import ru.shadowsparky.ui.components.LazyColumn
import ru.shadowsparky.ui.components.Loading
import ru.shadowsparky.ui.rememberBlurState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(component: ChatComponent, modifier: Modifier = Modifier) {
val state by component.state.subscribeAsState()
val blur = rememberBlurState()
AppScaffold(
appBar = {
TopAppBar(
title = { Text(stringResource(Res.string.chat_title)) },
modifier = Modifier.applyBlurEffect(blur)
)
},
modifier = modifier
) { paddingValues ->
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
when (val currentState = state) {
is ChatState.Loading -> {
Loading(Modifier.padding(paddingValues).fillMaxSize())
}
is ChatState.Error -> {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(paddingValues)
) {
Text(text = stringResource(Res.string.chat_load_error), color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { component.loadInitialMessages() }) {
Text(stringResource(Res.string.chat_retry_button))
}
}
}
is ChatState.Data -> {
ChatContent(
paddingValues,
blur,
state = currentState,
onLoadMore = { component.loadMoreMessages() },
onSendMessage = { text -> component.sendMessage(text) },
onDismissError = { component.clearSendError() }
)
}
}
}
}
}
@Composable
private fun ChatContent(
paddingValues: PaddingValues,
blurState: BlurState?,
state: ChatState.Data,
onLoadMore: () -> Unit,
onSendMessage: (String) -> Unit,
onDismissError: () -> Unit
) {
val listState = rememberLazyListState()
var textInput by remember { mutableStateOf("") }
val shouldLoadMore by remember {
derivedStateOf {
val layoutInfo = listState.layoutInfo
val totalItems = layoutInfo.totalItemsCount
val lastVisibleItemIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
totalItems > 0 && lastVisibleItemIndex >= totalItems - 2
}
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore) {
onLoadMore()
}
}
LaunchedEffect(state.messages.size) {
if (state.messages.isNotEmpty()) {
listState.animateScrollToItem(state.messages.lastIndex)
}
}
Column(modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
modifier = Modifier.applyBlurSource(blurState)
.weight(1f)
.fillMaxWidth(),
contentPadding = paddingValues,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(items = state.messages, key = { it.id ?: it.hashCode() }) { message ->
MessageBubble(message = message)
}
if (state.isSubsequentLoading) {
item {
Box(modifier = Modifier.fillMaxWidth().padding(8.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
}
}
state.sendError?.let { error ->
Snackbar(
action = {
TextButton(onClick = onDismissError) { Text(stringResource(Res.string.chat_ok)) }
},
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(error)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = textInput,
onValueChange = { textInput = it },
modifier = Modifier.weight(1f),
placeholder = { Text(stringResource(Res.string.chat_input_placeholder)) },
enabled = !state.isSending
)
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
if (textInput.isNotBlank()) {
onSendMessage(textInput)
textInput = ""
}
},
enabled = textInput.isNotBlank() && !state.isSending
) {
if (state.isSending) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Text(stringResource(Res.string.chat_send_button))
}
}
}
}
}
@Composable
private fun MessageBubble(message: Message) {
val isUser = message.role == ChatRoles.USER
val alignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart
val containerColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = alignment) {
Card(
colors = CardDefaults.cardColors(containerColor = containerColor),
modifier = Modifier.widthIn(max = 280.dp)
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(text = message.content, style = MaterialTheme.typography.bodyLarge)
}
}
}
}
@@ -1,6 +1,17 @@
package ru.shadowsparky.chat.domain
import kotlinx.serialization.Serializable
interface ChatRepository {
suspend fun query(): List<Message>
suspend fun query(afterId: Long?): List<Message>
suspend fun send(message: Message)
companion object {
const val ARG_AFTER_ID = "after_id"
}
}
@Serializable
data class MessagesResponse(
val content: List<Message>
)