diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/build.sbt b/build.sbt index 269d0bc..0517b1f 100644 --- a/build.sbt +++ b/build.sbt @@ -4,7 +4,7 @@ import org.scalajs.linker.interface.ModuleSplitStyle import scala.sys.process.* -lazy val projectVersion = "2.3.4" +lazy val projectVersion = "2.4.0" lazy val organizationName = "ru.trett" lazy val scala3Version = "3.7.4" lazy val circeVersion = "0.14.15" diff --git a/client/package-lock.json b/client/package-lock.json index dbe6806..b85b077 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1027,6 +1027,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/client/src/main/scala/client/Home.scala b/client/src/main/scala/client/Home.scala index b6ad44b..d88ca4d 100644 --- a/client/src/main/scala/client/Home.scala +++ b/client/src/main/scala/client/Home.scala @@ -52,53 +52,54 @@ object Home: case Failure(err) => handleError(err) } - def render: Element = div( - cls := "cards main-content", + def render: Element = div( - onMountBind(ctx => - refreshFeedsBus --> { page => - val response = getChannelsAndFeedsRequest(page) - val data = response.collectSuccess - val errors = response.collectFailure - data.addObserver(feedsObserver)(ctx.owner) - errors.addObserver(errorObserver)(ctx.owner) - } - ), + cls := "cards main-content", div( onMountBind(ctx => - markAllAsReadBus --> { _ => - val link = feedVar.now().map(_.link) - if (link.nonEmpty) { - val response = updateFeedRequest(link) - response.addObserver(itemClickObserver)(ctx.owner) - } + refreshFeedsBus --> { page => + val response = getChannelsAndFeedsRequest(page) + val data = response.collectSuccess + val errors = response.collectFailure + data.addObserver(feedsObserver)(ctx.owner) + errors.addObserver(errorObserver)(ctx.owner) } + ), + div( + onMountBind(ctx => + markAllAsReadBus --> { _ => + val link = feedVar.now().map(_.link) + if (link.nonEmpty) { + val response = updateFeedRequest(link) + response.addObserver(itemClickObserver)(ctx.owner) + } + } + ) + ), + div( + onMountBind(ctx => + refreshUnreadCountBus --> { _ => + val response = getUnreadCountRequest() + response.addObserver(unreadCountObserver)(ctx.owner) + } + ) ) ), + feeds(), div( - onMountBind(ctx => - refreshUnreadCountBus --> { _ => - val response = getUnreadCountRequest() - response.addObserver(unreadCountObserver)(ctx.owner) - } + display.flex, + justifyContent.center, + marginTop.px := 20, + marginBottom.px := 20, + Button( + _.design := ButtonDesign.Transparent, + _.icon := IconName.download, + "More News", + onClick.mapTo(feedVar.now().size / pageLimit + 1) --> Home.refreshFeedsBus, + hidden <-- feedVar.signal.map(xs => xs.isEmpty) ) ) - ), - feeds(), - div( - display.flex, - justifyContent.center, - marginTop.px := 20, - marginBottom.px := 20, - Button( - _.design := ButtonDesign.Transparent, - _.icon := IconName.download, - "More News", - onClick.mapTo(feedVar.now().size / pageLimit + 1) --> Home.refreshFeedsBus, - hidden <-- feedVar.signal.map(xs => xs.isEmpty) - ) ) - ) private def feeds(): Element = val response = getChannelsAndFeedsRequest(1) diff --git a/client/src/main/scala/client/Models.scala b/client/src/main/scala/client/Models.scala index 186059e..8232cf2 100644 --- a/client/src/main/scala/client/Models.scala +++ b/client/src/main/scala/client/Models.scala @@ -2,10 +2,28 @@ package client import com.raquo.airstream.state.{StrictSignal, Var} import ru.trett.rss.models.* +import io.circe.Decoder +import io.circe.generic.semiauto.* type ChannelList = List[ChannelData] type FeedItemList = List[FeedItemData] +object Decoders: + given Decoder[UserSettings] = deriveDecoder + given Decoder[SummarySuccess] = deriveDecoder + given Decoder[SummaryError] = deriveDecoder + given Decoder[SummaryResult] = Decoder.instance { cursor => + cursor.downField("type").as[String].flatMap { + case "success" => cursor.as[SummarySuccess] + case "error" => cursor.as[SummaryError] + case other => + Left( + io.circe.DecodingFailure(s"Unknown SummaryResult type: $other", cursor.history) + ) + } + } + given Decoder[SummaryResponse] = deriveDecoder + final class Model: val feedVar: Var[FeedItemList] = Var(List()) val channelVar: Var[ChannelList] = Var(List()) diff --git a/client/src/main/scala/client/NavBar.scala b/client/src/main/scala/client/NavBar.scala index f5c99be..7512031 100644 --- a/client/src/main/scala/client/NavBar.scala +++ b/client/src/main/scala/client/NavBar.scala @@ -25,19 +25,22 @@ object NavBar { cls := "sticky-navbar", ShellBar( _.primaryTitle := "RSS Reader", - _.notificationsCount <-- unreadCountSignal.map(count => - if (count > 0) count.toString else "" - ), - _.showNotifications <-- unreadCountSignal.map(_ > 0), + _.notificationsCount <-- unreadCountSignal.combineWith(settingsSignal).map { + case (count, settings) => + val show = !settings.exists(_.isAiMode) && count > 0 + if show then count.toString else "" + }, + _.showNotifications <-- unreadCountSignal.combineWith(settingsSignal).map { + case (count, settings) => !settings.exists(_.isAiMode) && count > 0 + }, _.slots.profile := Avatar(_.icon := IconName.customer, idAttr := profileId), _.slots.logo := Icon(_.name := IconName.home), - _.item( - _.icon := IconName.ai, - _.text := "Summary", - onClick.mapTo(()) --> { Router.currentPageVar.set(SummaryRoute) } - ), _.events.onProfileClick.map(item => Some(item.detail.targetRef)) --> popoverBus.writer, - _.events.onLogoClick.mapTo(()) --> { Router.currentPageVar.set(HomeRoute) }, + _.events.onLogoClick.mapTo(()) --> { _ => + settingsSignal.now() match + case Some(settings) => Router.toMainPage(settings) + case None => Router.currentPageVar.set(LoginRoute) + }, _.events.onNotificationsClick.mapTo(()) --> { EventBus.emit(Home.markAllAsReadBus -> ()) } @@ -59,7 +62,6 @@ object NavBar { "Update feeds", onClick .mapTo(()) - // TODO: show loading spinner .flatMap(_ => refreshFeedsRequest()) --> { _ => EventBus.emit( Home.refreshFeedsBus -> 1, diff --git a/client/src/main/scala/client/NetworkUtils.scala b/client/src/main/scala/client/NetworkUtils.scala index 54fa434..aefa34a 100644 --- a/client/src/main/scala/client/NetworkUtils.scala +++ b/client/src/main/scala/client/NetworkUtils.scala @@ -14,6 +14,7 @@ import org.scalajs.dom import scala.util.Failure import scala.util.Success import scala.util.Try +import ru.trett.rss.models.UserSettings object NetworkUtils { @@ -52,6 +53,14 @@ object NetworkUtils { .map(foreignHtmlElement) ) + import Decoders.given + + def ensureSettingsLoaded(): EventStream[Try[UserSettings]] = + FetchStream + .withDecoder(responseDecoder[UserSettings]) + .get("/api/user/settings") + .collect { case Success(Some(value)) => Success(value) } + def logout(): EventStream[Unit] = FetchStream.post("/api/logout", _.body("")).mapTo(()) } diff --git a/client/src/main/scala/client/Router.scala b/client/src/main/scala/client/Router.scala index 01e8397..bfe2254 100644 --- a/client/src/main/scala/client/Router.scala +++ b/client/src/main/scala/client/Router.scala @@ -1,10 +1,11 @@ package client import be.doeraene.webcomponents.ui5.Text -import com.raquo import com.raquo.airstream.state.Var import com.raquo.laminar.api.L.* import org.scalajs.dom +import ru.trett.rss.models.UserSettings +import scala.util.{Success, Failure} @main def createApp(): Unit = @@ -20,7 +21,10 @@ case object NotFoundRoute extends Route object Router: - val currentPageVar: Var[Route] = Var[Route](HomeRoute) + val currentPageVar: Var[Route] = Var[Route](LoginRoute) + def toMainPage(settings: UserSettings): Unit = + val mainPage = if settings.isAiMode then SummaryRoute else HomeRoute + currentPageVar.set(mainPage) private def login = LoginPage.render private def navbar = NavBar.render @@ -29,7 +33,15 @@ object Router: def settings: Element = SettingsPage.render def summary: Element = SummaryPage.render + private val model = AppState.model + private val root = div( + NetworkUtils.ensureSettingsLoaded() --> { + case Success(settings) => + model.settingsVar.set(Some(settings)) + toMainPage(settings) + case Failure(err) => NetworkUtils.handleError(err) + }, child <-- currentPageVar.signal.map { case LoginRoute => login case HomeRoute => div(navbar, notifications, home) diff --git a/client/src/main/scala/client/SettingsPage.scala b/client/src/main/scala/client/SettingsPage.scala index 9fcefee..a540468 100644 --- a/client/src/main/scala/client/SettingsPage.scala +++ b/client/src/main/scala/client/SettingsPage.scala @@ -40,13 +40,10 @@ object SettingsPage { case Failure(err) => handleError(err) } - given feedItemDecoder: Decoder[FeedItemData] = deriveDecoder - - given channelDecoder: Decoder[ChannelData] = deriveDecoder - - given settingsDecoder: Decoder[UserSettings] = deriveDecoder - - given settingsEncoder: Encoder[UserSettings] = deriveEncoder + import Decoders.given + given Decoder[FeedItemData] = deriveDecoder + given Decoder[ChannelData] = deriveDecoder + given Encoder[UserSettings] = deriveEncoder def render: Element = div( cls := "cards main-content", @@ -69,8 +66,10 @@ object SettingsPage { Link( "Return to feeds", _.icon := IconName.`nav-back`, - _.events.onClick.mapTo(HomeRoute) --> { - Router.currentPageVar.set + _.events.onClick.mapTo(()) --> { _ => + settingsSignal.now() match + case Some(settings) => Router.toMainPage(settings) + case None => Router.currentPageVar.set(LoginRoute) }, marginBottom.px := 20 ), @@ -117,6 +116,60 @@ object SettingsPage { ) ) ), + div( + formBlockStyle, + marginBottom.px := 16, + Label( + "App mode", + _.forId := "app-mode-cmb", + _.showColon := true, + _.wrappingType := WrappingType.None, + paddingRight.px := 20 + ), + Select( + _.id := "app-mode-cmb", + _.events.onChange + .map(_.detail.selectedOption.textContent) --> settingsVar + .updater[String]((a, b) => + a.map(x => x.copy(aiMode = Some(b == "AI Mode"))) + ), + Select.option( + _.selected <-- settingsSignal.map(_.exists(_.isAiMode)), + "AI Mode" + ), + Select.option( + _.selected <-- settingsSignal.map(_.exists(!_.isAiMode)), + "Regular Mode" + ) + ) + ), + div( + formBlockStyle, + marginBottom.px := 16, + Label( + "AI Model", + _.forId := "summary-model-cmb", + _.showColon := true, + _.wrappingType := WrappingType.None, + paddingRight.px := 20 + ), + Select( + _.id := "summary-model-cmb", + _.events.onChange + .map(_.detail.selectedOption.textContent) --> settingsVar + .updater[String]((a, b) => + a.map(x => x.copy(summaryModel = Some(b))) + ), + SummaryModel.all.map(model => + Select.option( + _.selected <-- settingsSignal.map(x => + x.flatMap(_.summaryModel).contains(model.displayName) + ), + model.displayName + ) + ) + ) + ), div( paddingTop.px := 10, Button( diff --git a/client/src/main/scala/client/SummaryPage.scala b/client/src/main/scala/client/SummaryPage.scala index 4dfe2c6..6f16c54 100644 --- a/client/src/main/scala/client/SummaryPage.scala +++ b/client/src/main/scala/client/SummaryPage.scala @@ -1,31 +1,184 @@ package client +import be.doeraene.webcomponents.ui5.* +import be.doeraene.webcomponents.ui5.configkeys.* +import client.NetworkUtils.* import com.raquo.laminar.api.L.* -import client.NetworkUtils.unsafeParseToHtmlFragment -import be.doeraene.webcomponents.ui5.Panel -import be.doeraene.webcomponents.ui5.BusyIndicator -import be.doeraene.webcomponents.ui5.UList - -object SummaryPage { - - def render: Element = { - val response = EventStream.fromValue("/api/summarize").flatMapWithStatus { req => - FetchStream.get(req) - } - val isLoading = response.map(_.isPending) +import ru.trett.rss.models.{SummaryResponse, SummarySuccess, SummaryError} + +import scala.util.{Failure, Success, Try} + +object SummaryPage: + + import Decoders.given + + private val model = AppState.model + + private case class PageState( + summaries: List[String] = List(), + isLoading: Boolean = true, + totalProcessed: Int = 0, + hasMore: Boolean = false, + funFact: Option[String] = None, + hasError: Boolean = false + ) + + private val stateVar: Var[PageState] = Var(PageState()) + private val stateSignal = stateVar.signal + private val loadMoreBus: EventBus[Unit] = new EventBus + + private def resetState(): Unit = stateVar.set(PageState()) + + private def fetchSummaryBatch(): EventStream[Try[Option[SummaryResponse]]] = + FetchStream + .withDecoder(responseDecoder[SummaryResponse]) + .get("/api/summarize") + + private val batchObserver: Observer[Try[Option[SummaryResponse]]] = Observer { + case Success(Some(resp)) if resp.funFact.isDefined => + stateVar.update(_.copy(isLoading = false, hasMore = false, funFact = resp.funFact)) + + case Success(Some(resp)) if resp.feedsProcessed > 0 => + val (newContent, isError) = resp.result match + case SummarySuccess(html) => (html, false) + case SummaryError(message) => (message, true) + stateVar.update(s => + s.copy( + isLoading = false, + summaries = s.summaries :+ newContent, + hasError = isError, + totalProcessed = s.totalProcessed + resp.feedsProcessed, + hasMore = resp.hasMore + ) + ) + Home.refreshUnreadCountBus.emit(()) + + case Success(_) => + stateVar.update(_.copy(isLoading = false, hasError = true)) + + case Failure(err) => + stateVar.update(_.copy(isLoading = false, hasError = true)) + handleError(err) + } + + def render: Element = + resetState() + val initialFetch = fetchSummaryBatch() div( cls := "main-content", - Panel( - _.headerText := "Summary", - BusyIndicator( - _.active <-- isLoading, - UList( - child <-- response - .splitStatus((resolved, _) => resolved.output, (pending, _) => "") - .map(unsafeParseToHtmlFragment(_)) - ) + initialFetch --> batchObserver, + onMountBind { ctx => + loadMoreBus.events.flatMapSwitch { _ => + stateVar.update(_.copy(isLoading = true)) + fetchSummaryBatch() + } --> batchObserver + }, + Card( + _.slots.header := CardHeader( + _.titleText := "AI Summary", + _.slots.avatar := Icon(_.name := IconName.`feeder-arrow`) + ), + div( + padding.px := 16, + fontFamily := "var(--sapFontFamily)", + fontSize.px := 15, + color := "var(--sapContent_LabelColor)", + lineHeight := "1.5", + child <-- stateSignal.map { state => + if state.isLoading && state.summaries.isEmpty then + div( + display.flex, + flexDirection.column, + alignItems.center, + justifyContent.center, + padding.px := 60, + BusyIndicator(_.active := true, _.size := BusyIndicatorSize.L), + p( + marginTop.px := 20, + color := "var(--sapContent_LabelColor)", + fontSize := "var(--sapFontSize)", + "Brewing your news digest..." + ) + ) + else emptyNode + }, + child <-- stateSignal.map { state => + state.funFact match + case Some(fact) if fact.nonEmpty => + div( + padding.px := 40, + textAlign.center, + Title(_.level := TitleLevel.H3, "All caught up!"), + p( + marginTop.px := 10, + marginBottom.px := 20, + color := "var(--sapContent_LabelColor)", + "You have no unread feeds." + ), + div( + marginTop.px := 20, + padding.px := 20, + backgroundColor := "var(--sapBackgroundColor)", + borderRadius.px := 8, + border := "1px solid var(--sapContent_ForegroundBorderColor)", + Title(_.level := TitleLevel.H5, "Did you know?"), + p(marginTop.px := 10, fact) + ) + ) + case _ => emptyNode + }, + div(children <-- stateSignal.map { state => + state.summaries.zipWithIndex.map { case (html, index) => + div( + unsafeParseToHtmlFragment(html), + if index < state.summaries.length - 1 then + hr( + marginTop.px := 20, + marginBottom.px := 20, + border := "none", + borderTop := "1px solid var(--sapContent_ForegroundBorderColor)" + ) + else emptyNode + ) + } + }), + child <-- stateSignal.map { state => + if state.isLoading && state.summaries.nonEmpty then + div( + display.flex, + alignItems.center, + justifyContent.center, + padding.px := 20, + gap.px := 10, + BusyIndicator(_.active := true, _.size := BusyIndicatorSize.S), + span("Loading more stories...") + ) + else emptyNode + }, + child <-- stateSignal.map { state => + if !state.isLoading && state.summaries.nonEmpty && state.funFact.isEmpty + then + div( + paddingTop.px := 20, + display.flex, + flexDirection.column, + alignItems.center, + gap.px := 16, + Text( + s"${state.totalProcessed} feeds summarized", + color := "var(--sapContent_LabelColor)" + ), + if state.hasMore && !state.hasError then + Button( + _.design := ButtonDesign.Emphasized, + _.icon := IconName.download, + "Load more news", + _.events.onClick.mapTo(()) --> loadMoreBus.writer + ) + else emptyNode + ) + else emptyNode + } ) ) ) - } -} diff --git a/scripts/local-docker/docker-compose.yml b/scripts/local-docker/docker-compose.yml index 8f57dc4..fdd0b72 100644 --- a/scripts/local-docker/docker-compose.yml +++ b/scripts/local-docker/docker-compose.yml @@ -24,7 +24,7 @@ services: - host.docker.internal:host-gateway server: - image: server:2.3.4 + image: server:2.4.0 container_name: rss_server restart: always depends_on: diff --git a/server/src/main/scala/ru/trett/rss/server/Server.scala b/server/src/main/scala/ru/trett/rss/server/Server.scala index 5a27537..7c653cb 100644 --- a/server/src/main/scala/ru/trett/rss/server/Server.scala +++ b/server/src/main/scala/ru/trett/rss/server/Server.scala @@ -29,6 +29,7 @@ import org.typelevel.log4cats.slf4j.* import org.typelevel.otel4s.instrumentation.ce.IORuntimeMetrics import org.typelevel.otel4s.oteljava.OtelJava import pureconfig.ConfigSource +import scala.concurrent.duration.* import ru.trett.rss.server.authorization.AuthFilter import ru.trett.rss.server.authorization.SessionManager import ru.trett.rss.server.config.AppConfig @@ -87,6 +88,7 @@ object Server extends IOApp: .surround { val client = EmberClientBuilder .default[IO] + .withTimeout(120.seconds) // Increased timeout for AI API calls .build transactor(appConfig.db).use { xa => client.use { client => diff --git a/server/src/main/scala/ru/trett/rss/server/codecs/SummaryCodecs.scala b/server/src/main/scala/ru/trett/rss/server/codecs/SummaryCodecs.scala new file mode 100644 index 0000000..192e78e --- /dev/null +++ b/server/src/main/scala/ru/trett/rss/server/codecs/SummaryCodecs.scala @@ -0,0 +1,36 @@ +package ru.trett.rss.server.codecs + +import io.circe.{Decoder, Encoder} +import io.circe.generic.semiauto.* +import io.circe.syntax.* +import ru.trett.rss.models.{SummaryResult, SummarySuccess, SummaryError, SummaryResponse} + +object SummaryCodecs: + given Encoder[SummarySuccess] = deriveEncoder + given Encoder[SummaryError] = deriveEncoder + + given Encoder[SummaryResult] = Encoder.instance { + case s @ SummarySuccess(_) => + s.asJson.mapObject(_.add("type", "success".asJson)) + case e @ SummaryError(_) => + e.asJson.mapObject(_.add("type", "error".asJson)) + } + + given Decoder[SummarySuccess] = deriveDecoder + given Decoder[SummaryError] = deriveDecoder + + given Decoder[SummaryResult] = + Decoder.instance { cursor => + cursor.downField("type").as[String].flatMap { + case "success" => cursor.as[SummarySuccess] + case "error" => cursor.as[SummaryError] + case other => + Left( + io.circe + .DecodingFailure(s"Unknown SummaryResult type: $other", cursor.history) + ) + } + } + + given Encoder[SummaryResponse] = deriveEncoder + given Decoder[SummaryResponse] = deriveDecoder diff --git a/server/src/main/scala/ru/trett/rss/server/controllers/SummarizeController.scala b/server/src/main/scala/ru/trett/rss/server/controllers/SummarizeController.scala index 533e7a3..d3c3935 100644 --- a/server/src/main/scala/ru/trett/rss/server/controllers/SummarizeController.scala +++ b/server/src/main/scala/ru/trett/rss/server/controllers/SummarizeController.scala @@ -2,19 +2,21 @@ package ru.trett.rss.server.controllers import cats.effect.IO import org.http4s.AuthedRoutes -import org.http4s.dsl.io._ +import org.http4s.circe.CirceEntityEncoder.* +import org.http4s.dsl.io.* import ru.trett.rss.server.models.User import ru.trett.rss.server.services.SummarizeService +import ru.trett.rss.server.codecs.SummaryCodecs.given -object SummarizeController { +object SummarizeController: - def routes(summarizeService: SummarizeService): AuthedRoutes[User, IO] = { - AuthedRoutes.of[User, IO] { case GET -> Root / "api" / "summarize" as user => - for { - summary <- summarizeService.getSummary(user) - response <- Ok(summary) - } yield response - } - } + object OffsetQueryParamMatcher extends OptionalQueryParamDecoderMatcher[Int]("offset") -} + def routes(summarizeService: SummarizeService): AuthedRoutes[User, IO] = + AuthedRoutes.of[User, IO] { + case GET -> Root / "api" / "summarize" :? OffsetQueryParamMatcher(offset) as user => + for + summary <- summarizeService.getSummary(user, offset.getOrElse(0)) + response <- Ok(summary) + yield response + } diff --git a/server/src/main/scala/ru/trett/rss/server/controllers/UserController.scala b/server/src/main/scala/ru/trett/rss/server/controllers/UserController.scala index 115872a..a8544b0 100644 --- a/server/src/main/scala/ru/trett/rss/server/controllers/UserController.scala +++ b/server/src/main/scala/ru/trett/rss/server/controllers/UserController.scala @@ -8,7 +8,7 @@ import org.http4s.circe.CirceEntityDecoder.* import org.http4s.circe.CirceEntityEncoder.* import org.http4s.dsl.io.* import org.typelevel.log4cats.{LoggerFactory, SelfAwareStructuredLogger} -import ru.trett.rss.models.{SummaryLanguage, UserSettings} +import ru.trett.rss.models.{SummaryLanguage, SummaryModel, UserSettings} import ru.trett.rss.server.models.User import ru.trett.rss.server.services.UserService @@ -34,13 +34,23 @@ object UserController { validatedLanguage = settings.summaryLanguage.flatMap { lang => SummaryLanguage.fromString(lang).map(_.displayName) } + validatedModel = settings.summaryModel.flatMap { model => + SummaryModel.fromString(model).map(_.displayName) + } updatedUser = user.copy(settings = - User.Settings(settings.hideRead, validatedLanguage) + User.Settings( + settings.hideRead, + validatedLanguage, + settings.aiMode, + validatedModel + ) ) result <- userService.updateUserSettings(updatedUser) - _ <- logger.info( - s"User: ${user.email} was updated with settings: ${user.settings}" - ) + _ <- logger.info(s"""User: ${user.email} was updated with settings: + |hideRead: ${settings.hideRead}, + |summaryLanguage: $validatedLanguage, + |aiMode: ${settings.aiMode}, + |summaryModel: $validatedModel""".stripMargin) _ <- cacheUpdater(updatedUser) response <- Ok(s"User created with result: $result") } yield response diff --git a/server/src/main/scala/ru/trett/rss/server/models/User.scala b/server/src/main/scala/ru/trett/rss/server/models/User.scala index 8247fb9..656cbb5 100644 --- a/server/src/main/scala/ru/trett/rss/server/models/User.scala +++ b/server/src/main/scala/ru/trett/rss/server/models/User.scala @@ -13,4 +13,11 @@ object User: given Decoder[User.Settings] = deriveDecoder given Encoder[User.Settings] = deriveEncoder - case class Settings(hideRead: Boolean = false, summaryLanguage: Option[String] = None) + case class Settings( + hideRead: Boolean = false, + summaryLanguage: Option[String] = None, + aiMode: Option[Boolean] = None, + summaryModel: Option[String] = None + ): + /** AI mode is the default. Returns true unless aiMode is explicitly set to false. */ + def isAiMode: Boolean = !aiMode.contains(false) diff --git a/server/src/main/scala/ru/trett/rss/server/repositories/FeedRepository.scala b/server/src/main/scala/ru/trett/rss/server/repositories/FeedRepository.scala index 2935c83..60436b6 100644 --- a/server/src/main/scala/ru/trett/rss/server/repositories/FeedRepository.scala +++ b/server/src/main/scala/ru/trett/rss/server/repositories/FeedRepository.scala @@ -42,8 +42,13 @@ class FeedRepository(xa: Transactor[IO]): """.query[Int].unique.transact(xa) def getUnreadFeeds(user: User, limit: Int): IO[List[Feed]] = + getUnreadFeeds(user, limit, 0) + + def getUnreadFeeds(user: User, limit: Int, offset: Int): IO[List[Feed]] = sql""" SELECT f.link, f.user_id, f.channel_id, f.title, f.description, f.pub_date, f.read FROM feeds f - WHERE f.user_id = ${user.id} AND f.read = false LIMIT $limit + WHERE f.user_id = ${user.id} AND f.read = false + ORDER BY f.pub_date DESC + LIMIT $limit OFFSET $offset """.query[Feed].to[List].transact(xa) diff --git a/server/src/main/scala/ru/trett/rss/server/services/SummarizeService.scala b/server/src/main/scala/ru/trett/rss/server/services/SummarizeService.scala index 96a0912..02d11dd 100644 --- a/server/src/main/scala/ru/trett/rss/server/services/SummarizeService.scala +++ b/server/src/main/scala/ru/trett/rss/server/services/SummarizeService.scala @@ -4,7 +4,6 @@ import cats.effect.IO import io.circe.Decoder import io.circe.generic.auto.* import io.circe.syntax.* -import org.http4s.syntax.all.uri import org.http4s.Header import org.http4s.Headers import org.http4s.Method @@ -16,13 +15,21 @@ import org.http4s.client.Client import org.typelevel.ci.* import org.typelevel.log4cats.Logger import org.typelevel.log4cats.LoggerFactory -import ru.trett.rss.models.SummaryLanguage +import ru.trett.rss.models.{ + SummaryLanguage, + SummaryModel, + SummaryResponse, + SummaryResult, + SummarySuccess, + SummaryError +} import ru.trett.rss.server.models.User import ru.trett.rss.server.repositories.FeedRepository import org.jsoup.Jsoup +import java.util.concurrent.TimeoutException case class Part(text: String) -case class Content(parts: List[Part]) +case class Content(parts: Option[List[Part]]) case class Candidate(content: Content) case class GeminiResponse(candidates: List[Candidate]) @@ -32,75 +39,145 @@ class SummarizeService(feedRepository: FeedRepository, client: Client[IO], apiKe given Decoder[GeminiResponse] = Decoder.forProduct1("candidates")(GeminiResponse.apply) private val logger: Logger[IO] = LoggerFactory[IO].getLogger - private val endpoint = - uri"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent" - private val summaryFeedLimit = 60 + private val batchSize = 30 - def getSummary(user: User): IO[String] = { - for { - feeds <- feedRepository.getUnreadFeeds(user, summaryFeedLimit) - text = feeds.map(_.description).mkString("\n") - strippedText = Jsoup.parse(text).text() - validatedLanguage = user.settings.summaryLanguage - .flatMap(SummaryLanguage.fromString) - .getOrElse(SummaryLanguage.English) - summary <- summarize(strippedText, validatedLanguage.displayName) - } yield summary - } + private def getEndpoint(modelId: String): Uri = + Uri.unsafeFromString( + s"https://generativelanguage.googleapis.com/v1beta/models/$modelId:generateContent" + ) - private def summarize(text: String, language: String): IO[String] = { - val request = Request[IO]( + private def buildGeminiRequest(modelId: String, prompt: String): Request[IO] = + Request[IO]( method = Method.POST, - uri = endpoint, + uri = getEndpoint(modelId), headers = Headers( Header.Raw(ci"X-goog-api-key", apiKey), Header.Raw(ci"Content-Type", "application/json") ) - ) - .withEntity( - Map( - "contents" -> List( - Map( - "parts" -> List( - Map( - "text" -> - s""" - You must follow these rules for your response: - 1. Provide only the raw text of the code. - 2. Do NOT use any markdown formatting. - 3. Do NOT wrap the code in backticks (```) - 4. Do NOT add any text, notes, or explanations. - 5. Respond with HTML. Topic headings should be wrapped in
tags. - 6. Use