diff --git a/apps/vbox/backend/build.gradle.kts b/apps/vbox/backend/build.gradle.kts index 8bd9ee3..c1fbcd3 100644 --- a/apps/vbox/backend/build.gradle.kts +++ b/apps/vbox/backend/build.gradle.kts @@ -21,6 +21,8 @@ dependencies { implementation(project(":libs:base")) implementation(project(":libs:http-client")) implementation(project(":feature:updater:updater-common")) + implementation(project(":feature:chat:chat-common")) + implementation(project(":feature:chat:chat-backend")) implementation(libs.ktor.server.core.jvm) implementation(libs.ktor.server.host.common.jvm) diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/chat/BackendChatRepository.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/chat/BackendChatRepository.kt new file mode 100644 index 0000000..4836067 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/chat/BackendChatRepository.kt @@ -0,0 +1,34 @@ +package ru.shadowsparky.vbox.backend.data.chat + +import org.koin.core.annotation.Factory +import ru.shadowsparky.chat.backend.domain.MessageStorage +import ru.shadowsparky.chat.backend.domain.ProcessUserMessageUseCase +import ru.shadowsparky.chat.domain.ChatRepository +import ru.shadowsparky.chat.domain.Message + +@Factory +class BackendChatRepositoryFactory( + private val storage: MessageStorage, + private val processUserMessageUseCase: ProcessUserMessageUseCase +) { + fun create(userId: Long): BackendChatRepository { + return BackendChatRepository(userId, storage, processUserMessageUseCase) + } +} + +class BackendChatRepository( + private val userId: Long, + private val storage: MessageStorage, + private val processUserMessageUseCase: ProcessUserMessageUseCase +) : ChatRepository { + + override suspend fun query(): List { + val history = storage.query(userId, limit = 10) + return history.sortedBy { it.timestamp } + } + + override suspend fun send(message: Message) { + storage.insert(userId, message) + processUserMessageUseCase.execute(userId) + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/chat/SqlMessageStorage.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/chat/SqlMessageStorage.kt new file mode 100644 index 0000000..0f846d1 --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/chat/SqlMessageStorage.kt @@ -0,0 +1,39 @@ +package ru.shadowsparky.vbox.backend.data.chat + +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Factory +import ru.shadowsparky.chat.backend.domain.MessageStorage +import ru.shadowsparky.chat.domain.Message +import ru.shadowsparky.domain.DispatcherProvider +import ru.shadowsparky.vbox.backend.AppDatabase + +@Factory +class SqlMessageStorage( + appDatabase: AppDatabase, + private val dispatcherProvider: DispatcherProvider +) : MessageStorage { + private val queries = appDatabase.chatQueries + + override suspend fun query(userId: Long, limit: Int): List = withContext(dispatcherProvider.io) { + val chatId = queries.getChatIdByUserId(userId).executeAsOneOrNull() ?: return@withContext emptyList() + val dbMessages = queries.getChatContext(chatId = chatId, limit = limit.toLong()).executeAsList() + dbMessages.map { dbMsg -> + Message( + id = dbMsg.id, + role = dbMsg.role, + content = dbMsg.content, + timestamp = checkNotNull(dbMsg.created_at?.toInstant()?.toEpochMilli()) { "created_at required" } + ) + } + } + + override suspend fun insert(userId: Long, message: Message): Long = withContext(dispatcherProvider.io) { + val chatId = queries.getChatIdByUserId(userId).executeAsOneOrNull() + ?: queries.createChat(userId = userId).executeAsOne() + queries.insertMessage( + chatId = chatId, + role = message.role, + content = message.content + ).executeAsOne() + } +} diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/EntryPoint.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/EntryPoint.kt index c497dd3..0431f83 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/EntryPoint.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/EntryPoint.kt @@ -8,6 +8,8 @@ import ru.shadowsparky.backend.domain.LoginVerifier import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler import ru.shadowsparky.vbox.backend.data.BackendUpdateFetcherFactory import ru.shadowsparky.vbox.backend.data.auth.AuthTokenRepositoryFactory +import ru.shadowsparky.vbox.backend.data.chat.BackendChatRepository +import ru.shadowsparky.vbox.backend.data.chat.BackendChatRepositoryFactory import ru.shadowsparky.vbox.backend.data.tags.MovieTagRepositoryFactory import ru.shadowsparky.vbox.backend.data.tags.UserTagRepositoryFactory import ru.shadowsparky.vbox.backend.di.factory.RecentlyWatchedRepositoryFactory @@ -24,7 +26,8 @@ class RoutingEntryPoint( val savedMovieFactory: SavedMovieRepositoryFactory, val movieTagFactory: MovieTagRepositoryFactory, val userTagFactory: UserTagRepositoryFactory, - val updateFetcherFactory: BackendUpdateFetcherFactory + val updateFetcherFactory: BackendUpdateFetcherFactory, + val chatRepositoryFactory: BackendChatRepositoryFactory ) @Single diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/AuthRouting.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/AuthRouting.kt index f16d5bd..ea873e1 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/AuthRouting.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/AuthRouting.kt @@ -45,5 +45,6 @@ fun Routing.setupAuthMethods( setupSavedMovie(savedMovieFactory) setupTagsRouting(userTagFactory, movieTagFactory) setupUpdates(updateFetcherFactory) + setupChat(chatRepositoryFactory) } } diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/ChatRouting.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/ChatRouting.kt new file mode 100644 index 0000000..497321a --- /dev/null +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/ChatRouting.kt @@ -0,0 +1,23 @@ +package ru.shadowsparky.vbox.backend.presentation.routing + +import io.ktor.http.HttpStatusCode +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import ru.shadowsparky.vbox.backend.data.chat.BackendChatRepositoryFactory +import ru.shadowsparky.vbox.backend.presentation.obtainUserId +import ru.shadowsparky.vbox.shared.domain.DYNAMIC_PREFIX + +fun Route.setupChat(chatRepositoryFactory: BackendChatRepositoryFactory) { + get("$DYNAMIC_PREFIX/chat/query") { + val repository = chatRepositoryFactory.create(call.obtainUserId()) + call.respond(repository.query()) + } + post("$DYNAMIC_PREFIX/chat/send") { + val repository = chatRepositoryFactory.create(call.obtainUserId()) + repository.send(call.receive()) + call.respond(HttpStatusCode.OK) + } +} diff --git a/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/chat.sq b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/chat.sq new file mode 100644 index 0000000..47b26d8 --- /dev/null +++ b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/chat.sq @@ -0,0 +1,22 @@ +getChatIdByUserId: +SELECT id +FROM chats +WHERE user_id = :userId AND is_active = TRUE +LIMIT 1; + +createChat: +INSERT INTO chats (user_id) +VALUES (:userId) +RETURNING id; + +insertMessage: +INSERT INTO messages (chat_id, role, content) +VALUES (:chatId, :role, :content) +RETURNING id; + +getChatContext: +SELECT id, role, content, created_at +FROM messages +WHERE chat_id = :chatId +ORDER BY created_at DESC +LIMIT :limit; diff --git a/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/init.sq b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/init.sq index bdca057..67b7f03 100644 --- a/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/init.sq +++ b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/init.sq @@ -78,3 +78,19 @@ CREATE TABLE movie_tag ( CONSTRAINT movie_tag_movie_fk FOREIGN KEY (movie_id) REFERENCES movie(movie_id), CONSTRAINT movie_tag_user_tag_fk FOREIGN KEY (tag_id) REFERENCES user_tag(id) ); + +CREATE TABLE IF NOT EXISTS chats ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id BIGINT NOT NULL REFERENCES "user"(user_id) ON DELETE CASCADE, + title VARCHAR(255) DEFAULT 'Новый подбор', + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS messages ( + id BIGSERIAL PRIMARY KEY, + chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE, + role VARCHAR(20) NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); diff --git a/feature/chat/chat-backend/src/main/kotlin/ru/shadowsparky/chat/backend/data/GigaChatService.kt b/feature/chat/chat-backend/src/main/kotlin/ru/shadowsparky/chat/backend/data/GigaChatService.kt index cf97b17..a66106d 100644 --- a/feature/chat/chat-backend/src/main/kotlin/ru/shadowsparky/chat/backend/data/GigaChatService.kt +++ b/feature/chat/chat-backend/src/main/kotlin/ru/shadowsparky/chat/backend/data/GigaChatService.kt @@ -10,7 +10,6 @@ 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 @@ -19,7 +18,6 @@ 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 @@ -35,28 +33,15 @@ class GigaChatService( 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") { + val response: GigaChatResponse = 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-Session-ID", staticSessionId) header("X-Client-ID", clientId) - setBody(body) + setBody(requestBody) }.body() + return response.choices.firstOrNull()?.message?.content + ?: error("unexpected response $response") } private fun createGigaChatHttpClient(): HttpClient { diff --git a/feature/chat/chat-backend/src/main/kotlin/ru/shadowsparky/chat/backend/domain/ProcessUserMessageUseCase.kt b/feature/chat/chat-backend/src/main/kotlin/ru/shadowsparky/chat/backend/domain/ProcessUserMessageUseCase.kt index 911cd96..9bbe0bc 100644 --- a/feature/chat/chat-backend/src/main/kotlin/ru/shadowsparky/chat/backend/domain/ProcessUserMessageUseCase.kt +++ b/feature/chat/chat-backend/src/main/kotlin/ru/shadowsparky/chat/backend/domain/ProcessUserMessageUseCase.kt @@ -9,8 +9,7 @@ class ProcessUserMessageUseCase( private val storage: MessageStorage, private val chatService: ChatService ) { - suspend fun execute(userId: Long, userMessage: Message): Message { - storage.insert(userId, userMessage) + suspend fun execute(userId: Long): Message { val history = storage.query(userId, limit = 10).toList() val sortedHistory = history.sortedBy { it.timestamp } val systemMessage = Message( diff --git a/feature/chat/chat-common/src/commonMain/kotlin/ru/shadowsparky/chat/domain/ChatRepository.kt b/feature/chat/chat-common/src/commonMain/kotlin/ru/shadowsparky/chat/domain/ChatRepository.kt index 1400566..7211b26 100644 --- a/feature/chat/chat-common/src/commonMain/kotlin/ru/shadowsparky/chat/domain/ChatRepository.kt +++ b/feature/chat/chat-common/src/commonMain/kotlin/ru/shadowsparky/chat/domain/ChatRepository.kt @@ -1,9 +1,6 @@ package ru.shadowsparky.chat.domain -import kotlinx.coroutines.flow.Flow - interface ChatRepository { - val messages: Flow - - suspend fun sendMessage(message: Message) + suspend fun query(): List + suspend fun send(message: Message) }