add chat basics
This commit is contained in:
@@ -27,7 +27,7 @@ dependencies {
|
|||||||
implementation(libs.activity.compose)
|
implementation(libs.activity.compose)
|
||||||
implementation(libs.material)
|
implementation(libs.material)
|
||||||
implementation(project(":apps:experimentarium:shared"))
|
implementation(project(":apps:experimentarium:shared"))
|
||||||
implementation(project(":libs:video-player"))
|
implementation(project(":feature:video-player"))
|
||||||
implementation(project(":libs:ui"))
|
implementation(project(":libs:ui"))
|
||||||
implementation(project(":libs:base"))
|
implementation(project(":libs:base"))
|
||||||
testImplementation(libs.koin.test)
|
testImplementation(libs.koin.test)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ kotlin {
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
implementation(project(":libs:base"))
|
implementation(project(":libs:base"))
|
||||||
implementation(project(":libs:video-player"))
|
implementation(project(":feature:video-player"))
|
||||||
}
|
}
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ kotlin {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":libs:base"))
|
implementation(project(":libs:base"))
|
||||||
implementation(project(":libs:ui"))
|
implementation(project(":libs:ui"))
|
||||||
implementation(project(":libs:video-player"))
|
implementation(project(":feature:video-player"))
|
||||||
implementation(project(":apps:experimentarium:shared"))
|
implementation(project(":apps:experimentarium:shared"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ dependencies {
|
|||||||
implementation(project(":libs:backend-base"))
|
implementation(project(":libs:backend-base"))
|
||||||
implementation(project(":libs:base"))
|
implementation(project(":libs:base"))
|
||||||
implementation(project(":libs:http-client"))
|
implementation(project(":libs:http-client"))
|
||||||
implementation(project(":libs:updater:updater-common"))
|
implementation(project(":feature:updater:updater-common"))
|
||||||
|
|
||||||
implementation(libs.ktor.server.core.jvm)
|
implementation(libs.ktor.server.core.jvm)
|
||||||
implementation(libs.ktor.server.host.common.jvm)
|
implementation(libs.ktor.server.host.common.jvm)
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ dependencies {
|
|||||||
implementation(project(":libs:base"))
|
implementation(project(":libs:base"))
|
||||||
implementation(project(":libs:ui"))
|
implementation(project(":libs:ui"))
|
||||||
implementation(project(":libs:http-client"))
|
implementation(project(":libs:http-client"))
|
||||||
implementation(project(":libs:video-player"))
|
implementation(project(":feature:video-player"))
|
||||||
implementation(project(":libs:updater:updater-client"))
|
implementation(project(":feature:updater:updater-client"))
|
||||||
debugImplementation(libs.ui.tooling)
|
debugImplementation(libs.ui.tooling)
|
||||||
implementation(libs.ui.tooling.preview)
|
implementation(libs.ui.tooling.preview)
|
||||||
testImplementation(libs.koin.test)
|
testImplementation(libs.koin.test)
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ kotlin {
|
|||||||
implementation(project(":libs:base"))
|
implementation(project(":libs:base"))
|
||||||
implementation(project(":libs:ui"))
|
implementation(project(":libs:ui"))
|
||||||
implementation(project(":libs:http-client"))
|
implementation(project(":libs:http-client"))
|
||||||
implementation(project(":libs:video-player"))
|
implementation(project(":feature:video-player"))
|
||||||
implementation(project(":libs:updater:updater-client"))
|
implementation(project(":feature:updater:updater-client"))
|
||||||
}
|
}
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ kotlin {
|
|||||||
implementation(project(":libs:base"))
|
implementation(project(":libs:base"))
|
||||||
implementation(project(":libs:ui"))
|
implementation(project(":libs:ui"))
|
||||||
implementation(project(":libs:http-client"))
|
implementation(project(":libs:http-client"))
|
||||||
implementation(project(":libs:video-player"))
|
implementation(project(":feature:video-player"))
|
||||||
implementation(project(":libs:updater:updater-client"))
|
implementation(project(":feature:updater:updater-client"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
+10
@@ -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
|
||||||
+97
@@ -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)
|
||||||
|
}
|
||||||
+58
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -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
|
||||||
|
}
|
||||||
+8
@@ -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
|
||||||
|
}
|
||||||
+35
@@ -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. На нерелевантные темы не общайся, переводи разговор на кино в свиной тематике."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
+9
@@ -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)
|
||||||
|
}
|
||||||
+7
@@ -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
|
||||||
|
)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/build
|
||||||
|
/bin
|
||||||
+1
-1
@@ -12,7 +12,7 @@ kotlin {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":libs:ui"))
|
implementation(project(":libs:ui"))
|
||||||
implementation(project(":libs:base"))
|
implementation(project(":libs:base"))
|
||||||
api(project(":libs:updater:updater-common"))
|
api(project(":feature:updater:updater-common"))
|
||||||
implementation(libs.ui.tooling.preview)
|
implementation(libs.ui.tooling.preview)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/build
|
||||||
|
/bin
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/build
|
||||||
|
/bin
|
||||||
+6
-3
@@ -42,6 +42,9 @@ include(":libs:backend-base")
|
|||||||
include(":libs:base")
|
include(":libs:base")
|
||||||
include(":libs:ui")
|
include(":libs:ui")
|
||||||
include(":libs:http-client")
|
include(":libs:http-client")
|
||||||
include(":libs:video-player")
|
include(":feature:video-player")
|
||||||
include(":libs:updater:updater-client")
|
include(":feature:updater:updater-client")
|
||||||
include(":libs:updater:updater-common")
|
include(":feature:updater:updater-common")
|
||||||
|
include(":feature:chat:chat-client")
|
||||||
|
include(":feature:chat:chat-common")
|
||||||
|
include(":feature:chat:chat-backend")
|
||||||
|
|||||||
Reference in New Issue
Block a user