add chat basics
This commit is contained in:
@@ -27,7 +27,7 @@ dependencies {
|
||||
implementation(libs.activity.compose)
|
||||
implementation(libs.material)
|
||||
implementation(project(":apps:experimentarium:shared"))
|
||||
implementation(project(":libs:video-player"))
|
||||
implementation(project(":feature:video-player"))
|
||||
implementation(project(":libs:ui"))
|
||||
implementation(project(":libs:base"))
|
||||
testImplementation(libs.koin.test)
|
||||
|
||||
@@ -9,7 +9,7 @@ kotlin {
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(project(":libs:base"))
|
||||
implementation(project(":libs:video-player"))
|
||||
implementation(project(":feature:video-player"))
|
||||
}
|
||||
androidMain.dependencies {
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ kotlin {
|
||||
dependencies {
|
||||
implementation(project(":libs:base"))
|
||||
implementation(project(":libs:ui"))
|
||||
implementation(project(":libs:video-player"))
|
||||
implementation(project(":feature:video-player"))
|
||||
implementation(project(":apps:experimentarium:shared"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ dependencies {
|
||||
implementation(project(":libs:backend-base"))
|
||||
implementation(project(":libs:base"))
|
||||
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.host.common.jvm)
|
||||
|
||||
@@ -26,8 +26,8 @@ dependencies {
|
||||
implementation(project(":libs:base"))
|
||||
implementation(project(":libs:ui"))
|
||||
implementation(project(":libs:http-client"))
|
||||
implementation(project(":libs:video-player"))
|
||||
implementation(project(":libs:updater:updater-client"))
|
||||
implementation(project(":feature:video-player"))
|
||||
implementation(project(":feature:updater:updater-client"))
|
||||
debugImplementation(libs.ui.tooling)
|
||||
implementation(libs.ui.tooling.preview)
|
||||
testImplementation(libs.koin.test)
|
||||
|
||||
@@ -25,8 +25,8 @@ kotlin {
|
||||
implementation(project(":libs:base"))
|
||||
implementation(project(":libs:ui"))
|
||||
implementation(project(":libs:http-client"))
|
||||
implementation(project(":libs:video-player"))
|
||||
implementation(project(":libs:updater:updater-client"))
|
||||
implementation(project(":feature:video-player"))
|
||||
implementation(project(":feature:updater:updater-client"))
|
||||
}
|
||||
androidMain.dependencies {
|
||||
implementation(libs.androidx.appcompat)
|
||||
|
||||
@@ -19,8 +19,8 @@ kotlin {
|
||||
implementation(project(":libs:base"))
|
||||
implementation(project(":libs:ui"))
|
||||
implementation(project(":libs:http-client"))
|
||||
implementation(project(":libs:video-player"))
|
||||
implementation(project(":libs:updater:updater-client"))
|
||||
implementation(project(":feature:video-player"))
|
||||
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 {
|
||||
implementation(project(":libs:ui"))
|
||||
implementation(project(":libs:base"))
|
||||
api(project(":libs:updater:updater-common"))
|
||||
api(project(":feature:updater:updater-common"))
|
||||
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:ui")
|
||||
include(":libs:http-client")
|
||||
include(":libs:video-player")
|
||||
include(":libs:updater:updater-client")
|
||||
include(":libs:updater:updater-common")
|
||||
include(":feature:video-player")
|
||||
include(":feature:updater:updater-client")
|
||||
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