add chat basics

This commit is contained in:
2026-07-28 20:45:30 +03:00
parent 1f90bf67ab
commit 644bcf9895
72 changed files with 323 additions and 14 deletions
+2
View File
@@ -0,0 +1,2 @@
/build
/bin
@@ -0,0 +1,12 @@
plugins {
alias(libs.plugins.convention.jvm)
alias(libs.plugins.convention.ktor.client)
alias(libs.plugins.convention.koin)
alias(libs.plugins.convention.serialization)
}
dependencies {
implementation(project(":libs:backend-base"))
implementation(project(":libs:http-client"))
implementation(project(":feature:chat:chat-common"))
}
@@ -0,0 +1,10 @@
package ru.shadowsparky.chat.backend
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Configuration
import org.koin.core.annotation.Module
@Module
@ComponentScan
@Configuration
class BackendChatModule
@@ -0,0 +1,97 @@
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.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.HttpStatusCode
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 ru.shadowsparky.chat.backend.domain.ChatService
import ru.shadowsparky.chat.domain.Message
import ru.shadowsparky.http.domain.HttpException
import java.util.UUID
@Factory
class GigaChatService(
private val tokenManager: GigaChatTokenManager,
private val json: Json
) : ChatService {
private val httpClient by lazy { createGigaChatHttpClient() }
override suspend fun getCompletion(userId: Long, messages: List<Message>): String {
val clientId = "vbox-$userId"
val staticSessionId = UUID.nameUUIDFromBytes(clientId.toByteArray()).toString()
val requestBody = GigaChatRequest(
messages = messages.map { GigaChatMessageDto(role = it.role, content = it.content) }
)
val response = try {
executeRequest(clientId, staticSessionId, requestBody)
} catch (e: HttpException) {
if (e.httpCode == HttpStatusCode.Unauthorized.value) {
tokenManager.forceRefreshToken()
executeRequest(clientId, staticSessionId, requestBody)
} else {
throw e
}
}
return response.choices.firstOrNull()?.message?.content
?: error("unexpected response $response")
}
private suspend fun executeRequest(clientId: String, sessionId: String, body: GigaChatRequest): GigaChatResponse {
return httpClient.post("https://api.giga.chat/v1/chat/completions") {
contentType(ContentType.Application.Json)
header("X-Request-ID", UUID.randomUUID().toString())
header("X-Session-ID", sessionId)
header("X-Client-ID", clientId)
setBody(body)
}.body()
}
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",
val stream: Boolean = false,
@SerialName("update_interval")
val updateInterval: Int = 0,
val messages: List<GigaChatMessageDto>
)
@Serializable
private data class GigaChatMessageDto(
val role: String,
val content: String
)
@Serializable
private data class GigaChatResponse(val choices: List<GigaChatChoiceDto>)
@Serializable
private data class GigaChatChoiceDto(val message: GigaChatMessageDto)
}
@@ -0,0 +1,58 @@
package ru.shadowsparky.chat.backend.data
import io.ktor.client.HttpClient
import io.ktor.client.call.body
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
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.koin.core.annotation.Single
import ru.shadowsparky.backend.data.EnvFetcher
import java.util.UUID
@Single
class GigaChatTokenManager(
private val authClient: HttpClient,
private val json: Json,
private val envFetcher: EnvFetcher
) {
private val scope: String = "GIGACHAT_API_PERS"
private val mutex = Mutex()
private var cachedToken: String? = null
suspend fun getToken(): String = mutex.withLock {
cachedToken ?: fetchNewToken()
}
suspend fun forceRefreshToken(): String = mutex.withLock {
fetchNewToken()
}
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 {
append("scope", scope)
}))
}.body()
val token = json.parseToJsonElement(responseString).jsonObject["access_token"]
?.jsonPrimitive
?.content
?: error("Unable to fetch token. response $responseString")
cachedToken = token
return token
}
}
@@ -0,0 +1,7 @@
package ru.shadowsparky.chat.backend.domain
import ru.shadowsparky.chat.domain.Message
interface ChatService {
suspend fun getCompletion(userId: Long, messages: List<Message>): String
}
@@ -0,0 +1,8 @@
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 insert(userId: Long, message: Message): Long
}
@@ -0,0 +1,35 @@
package ru.shadowsparky.chat.backend.domain
import org.koin.core.annotation.Factory
import ru.shadowsparky.chat.domain.ChatRoles
import ru.shadowsparky.chat.domain.Message
@Factory
class ProcessUserMessageUseCase(
private val storage: MessageStorage,
private val chatService: ChatService
) {
suspend fun execute(userId: Long, userMessage: Message): Message {
storage.insert(userId, userMessage)
val history = storage.query(userId, limit = 10).toList()
val sortedHistory = history.sortedBy { it.timestamp }
val systemMessage = Message(
role = ChatRoles.SYSTEM,
content = SYSTEM_PROMPT,
timestamp = System.currentTimeMillis()
)
val fullContext = listOf(systemMessage) + sortedHistory
val aiResponseText = chatService.getCompletion(userId, fullContext)
val aiMessage = Message(
role = ChatRoles.ASSISTANT,
content = aiResponseText,
timestamp = System.currentTimeMillis()
)
val messageId = storage.insert(userId, aiMessage)
return aiMessage.copy(id = messageId)
}
companion object {
private const val SYSTEM_PROMPT = "Ты ИИ-помощник приложения \"Свиное рыло\". Ищи юзерам фильмы и сериалы. Правила: 1. Называй юзера \"свинорылец\" (Пример: \"Здорово, свинорылец!\"). 2. Общайся по-свойски, с юмором, иронично. 3. Если юзер сомневается, задай 2 наводящих вопроса. 4. Выдавай строго до 5 позиций максимум, даже если просят больше. Описание короткое (1-2 предложения). 5. Каждое название фильма/сериала ВСЕГДА строго оборачивай в теги [MOVIE]Название[/MOVIE]. 6. На нерелевантные темы не общайся, переводи разговор на кино в свиной тематике."
}
}
+2
View File
@@ -0,0 +1,2 @@
/build
/bin
+30
View File
@@ -0,0 +1,30 @@
plugins {
alias(libs.plugins.convention.kmp)
alias(libs.plugins.convention.android.lib)
alias(libs.plugins.convention.serialization)
alias(libs.plugins.convention.koin)
alias(libs.plugins.convention.compose)
}
kotlin {
sourceSets {
val commonMain by getting {
dependencies {
implementation(project(":libs:ui"))
implementation(project(":libs:base"))
api(project(":feature:chat:chat-common"))
implementation(libs.ui.tooling.preview)
}
}
val androidMain by getting {
dependencies {
implementation(libs.androidx.core)
}
}
}
android { namespace = "ru.shadowsparky.chat" }
}
dependencies {
androidRuntimeClasspath(libs.ui.tooling)
}
+2
View File
@@ -0,0 +1,2 @@
/build
/bin
+16
View File
@@ -0,0 +1,16 @@
plugins {
alias(libs.plugins.convention.kmp)
alias(libs.plugins.convention.android.lib)
alias(libs.plugins.convention.serialization)
}
kotlin {
sourceSets {
val commonMain by getting {
dependencies {
implementation(project(":libs:base"))
}
}
}
android { namespace = "ru.shadowsparky.chat.common" }
}
@@ -0,0 +1,9 @@
package ru.shadowsparky.chat.domain
import kotlinx.coroutines.flow.Flow
interface ChatRepository {
val messages: Flow<Message>
suspend fun sendMessage(message: Message)
}
@@ -0,0 +1,7 @@
package ru.shadowsparky.chat.domain
object ChatRoles {
const val USER = "user"
const val SYSTEM = "system"
const val ASSISTANT = "assistant"
}
@@ -0,0 +1,11 @@
package ru.shadowsparky.chat.domain
import kotlinx.serialization.Serializable
@Serializable
data class Message(
val id: Long? = null,
val role: String,
val content: String,
val timestamp: Long
)