From 924d0d5a606b5860d5417d038f6d776f7082a372 Mon Sep 17 00:00:00 2001 From: trett Date: Sat, 17 Jan 2026 13:33:03 +0100 Subject: [PATCH 1/6] AI-first approach --- client/package-lock.json | 1 + client/src/main/scala/client/Home.scala | 11 +- client/src/main/scala/client/Models.scala | 28 ++ client/src/main/scala/client/NavBar.scala | 21 +- client/src/main/scala/client/Router.scala | 20 +- .../src/main/scala/client/SettingsPage.scala | 36 ++- .../src/main/scala/client/SummaryPage.scala | 243 ++++++++++++++++-- .../scala/ru/trett/rss/server/Server.scala | 2 + .../rss/server/codecs/SummaryCodecs.scala | 32 +++ .../controllers/SummarizeController.scala | 24 +- .../server/controllers/UserController.scala | 6 +- .../ru/trett/rss/server/models/User.scala | 6 +- .../server/repositories/FeedRepository.scala | 7 +- .../server/services/SummarizeService.scala | 168 +++++++++--- .../rss/server/services/UserService.scala | 7 +- .../ru/trett/rss/models/SummaryResponse.scala | 14 + .../ru/trett/rss/models/UserSettings.scala | 7 +- 17 files changed, 545 insertions(+), 88 deletions(-) create mode 100644 server/src/main/scala/ru/trett/rss/server/codecs/SummaryCodecs.scala create mode 100644 shared/src/main/scala/ru/trett/rss/models/SummaryResponse.scala 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..913df9d 100644 --- a/client/src/main/scala/client/Home.scala +++ b/client/src/main/scala/client/Home.scala @@ -8,7 +8,7 @@ import com.raquo.laminar.nodes.ReactiveHtmlElement import io.circe.Decoder import io.circe.generic.semiauto.* import io.circe.syntax.* -import ru.trett.rss.models.{ChannelData, FeedItemData} +import ru.trett.rss.models.{ChannelData, FeedItemData, UserSettings} import java.time.format.DateTimeFormatter import java.time.{LocalDateTime, ZoneOffset} @@ -29,6 +29,7 @@ object Home: given Decoder[FeedItemData] = deriveDecoder given Decoder[ChannelData] = deriveDecoder + given Decoder[UserSettings] = deriveDecoder given Conversion[LocalDateTime, String] with { def apply(date: LocalDateTime): String = dateTimeFormatter.format(date) } @@ -52,8 +53,14 @@ object Home: case Failure(err) => handleError(err) } - def render: Element = div( + def render: Element = + // Fetch settings on mount + val settingsFetch = model.ensureSettingsLoaded() + + div( cls := "cards main-content", + // Wire settings fetch to settings var + settingsFetch.collectSuccess --> settingsVar.writer, div( onMountBind(ctx => refreshFeedsBus --> { page => diff --git a/client/src/main/scala/client/Models.scala b/client/src/main/scala/client/Models.scala index 186059e..ac4c329 100644 --- a/client/src/main/scala/client/Models.scala +++ b/client/src/main/scala/client/Models.scala @@ -1,12 +1,19 @@ package client import com.raquo.airstream.state.{StrictSignal, Var} +import com.raquo.laminar.api.L.* import ru.trett.rss.models.* +import client.NetworkUtils.responseDecoder +import io.circe.Decoder +import io.circe.generic.semiauto.* +import scala.util.{Try, Success, Failure} type ChannelList = List[ChannelData] type FeedItemList = List[FeedItemData] final class Model: + given Decoder[UserSettings] = deriveDecoder + val feedVar: Var[FeedItemList] = Var(List()) val channelVar: Var[ChannelList] = Var(List()) val settingsVar: Var[Option[UserSettings]] = Var(Option.empty) @@ -15,3 +22,24 @@ final class Model: val channelSignal: StrictSignal[ChannelList] = channelVar.signal val settingsSignal: StrictSignal[Option[UserSettings]] = settingsVar.signal val unreadCountSignal: StrictSignal[Int] = unreadCountVar.signal + + private val settingsLoadingVar: Var[Boolean] = Var(false) + + // Centralized settings fetch - prevents race conditions + def ensureSettingsLoaded(): EventStream[Try[Option[UserSettings]]] = + if (settingsVar.now().isDefined || settingsLoadingVar.now()) then + EventStream.empty + else + settingsLoadingVar.set(true) + FetchStream + .withDecoder(responseDecoder[Option[UserSettings]]) + .get("/api/user/settings") + .map { + case Success(Some(value)) => Success(value) + case Success(None) => Failure(new RuntimeException("Failed to decode settings response")) + case Failure(err) => Failure(err) + } + .map { result => + settingsLoadingVar.set(false) + result + } diff --git a/client/src/main/scala/client/NavBar.scala b/client/src/main/scala/client/NavBar.scala index f5c99be..6d54a06 100644 --- a/client/src/main/scala/client/NavBar.scala +++ b/client/src/main/scala/client/NavBar.scala @@ -25,19 +25,20 @@ 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 isRegularMode = settings.flatMap(_.aiMode).contains(false) + if (isRegularMode && count > 0) count.toString else "" + }, + _.showNotifications <-- unreadCountSignal.combineWith(settingsSignal).map { + case (count, settings) => + val isRegularMode = settings.flatMap(_.aiMode).contains(false) + isRegularMode && 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(()) --> { Router.currentPageVar.set(SummaryRoute) }, _.events.onNotificationsClick.mapTo(()) --> { EventBus.emit(Home.markAllAsReadBus -> ()) } diff --git a/client/src/main/scala/client/Router.scala b/client/src/main/scala/client/Router.scala index 01e8397..42c7f0c 100644 --- a/client/src/main/scala/client/Router.scala +++ b/client/src/main/scala/client/Router.scala @@ -1,7 +1,6 @@ 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 @@ -20,7 +19,8 @@ case object NotFoundRoute extends Route object Router: - val currentPageVar: Var[Route] = Var[Route](HomeRoute) + val currentPageVar: Var[Route] = Var[Route](SummaryRoute) + private val initialRouteSetVar: Var[Boolean] = Var(false) private def login = LoginPage.render private def navbar = NavBar.render @@ -38,7 +38,21 @@ object Router: case ErrorRoute => div(Text("An error occured")) case NotFoundRoute => div(Text("Not Found")) }, - className := "app-container" + className := "app-container", + AppState.model.settingsSignal --> { settings => + if (!initialRouteSetVar.now() && settings.isDefined) { + val isRegularMode = settings.flatMap(_.aiMode).contains(false) + val currentRoute = currentPageVar.now() + // Only redirect if we're still on the default route + if (currentRoute == SummaryRoute && isRegularMode) { + currentPageVar.set(HomeRoute) + } else if (currentRoute == LoginRoute) { + // After login, navigate to the appropriate page + currentPageVar.set(if (isRegularMode) HomeRoute else SummaryRoute) + } + initialRouteSetVar.set(true) + } + } ) def appElement(): Element = div(root) diff --git a/client/src/main/scala/client/SettingsPage.scala b/client/src/main/scala/client/SettingsPage.scala index 9fcefee..cd799ed 100644 --- a/client/src/main/scala/client/SettingsPage.scala +++ b/client/src/main/scala/client/SettingsPage.scala @@ -69,8 +69,9 @@ object SettingsPage { Link( "Return to feeds", _.icon := IconName.`nav-back`, - _.events.onClick.mapTo(HomeRoute) --> { - Router.currentPageVar.set + _.events.onClick.mapTo(settingsSignal.now()) --> { settings => + val isRegularMode = settings.flatMap(_.aiMode).contains(false) + Router.currentPageVar.set(if (isRegularMode) HomeRoute else SummaryRoute) }, marginBottom.px := 20 ), @@ -117,6 +118,37 @@ 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(x => + !x.flatMap(_.aiMode).contains(false) + ), + "AI Mode" + ), + Select.option( + _.selected <-- settingsSignal.map(x => + x.flatMap(_.aiMode).contains(false) + ), + "Regular Mode" + ) + ) + ), 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..6947210 100644 --- a/client/src/main/scala/client/SummaryPage.scala +++ b/client/src/main/scala/client/SummaryPage.scala @@ -1,31 +1,234 @@ 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 +import io.circe.Decoder +import io.circe.generic.semiauto.* +import ru.trett.rss.models.{SummaryResponse, SummaryResult, SummarySuccess, SummaryError, UserSettings} -object SummaryPage { +import scala.util.{Failure, Success, Try} - def render: Element = { - val response = EventStream.fromValue("/api/summarize").flatMapWithStatus { req => - FetchStream.get(req) +object SummaryPage: + + 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)) + } } - val isLoading = response.map(_.isPending) + + given Decoder[SummaryResponse] = deriveDecoder + given Decoder[UserSettings] = deriveDecoder + + private val model = AppState.model + import model.* + + private val summariesVar: Var[List[String]] = Var(List()) + private val summariesSignal = summariesVar.signal + private val isLoadingVar: Var[Boolean] = Var(true) + private val isLoadingSignal = isLoadingVar.signal + private val totalProcessedVar: Var[Int] = Var(0) + private val noFeedsVar: Var[Boolean] = Var(false) + private val noFeedsSignal = noFeedsVar.signal + private val hasMoreVar: Var[Boolean] = Var(false) + private val hasMoreSignal = hasMoreVar.signal + private val funFactVar: Var[Option[String]] = Var(None) + private val funFactSignal = funFactVar.signal + private val hasErrorVar: Var[Boolean] = Var(false) + private val hasErrorSignal = hasErrorVar.signal + private val loadMoreBus: EventBus[Unit] = new EventBus + + private def resetState(): Unit = + summariesVar.set(List()) + isLoadingVar.set(true) + totalProcessedVar.set(0) + noFeedsVar.set(false) + hasMoreVar.set(false) + funFactVar.set(None) + hasErrorVar.set(false) + + private def fetchSummaryBatch(): EventStream[Try[SummaryResponse]] = + FetchStream + .withDecoder(responseDecoder[SummaryResponse]) + .get("/api/summarize") + .map { + case Success(Some(value)) => Success(value) + case Success(None) => Failure(new RuntimeException("Failed to decode summary response")) + case Failure(err) => Failure(err) + } + + private val batchObserver: Observer[Try[SummaryResponse]] = Observer { + case Success(response) => + isLoadingVar.set(false) + + if response.noFeeds then + // No feeds available + noFeedsVar.set(true) + hasMoreVar.set(false) + funFactVar.set(response.funFact) + else if response.feedsProcessed > 0 then + response.result match + case SummarySuccess(html) => + hasErrorVar.set(false) + summariesVar.update(_ :+ html) + case SummaryError(message) => + hasErrorVar.set(true) + summariesVar.update(_ :+ message) + + totalProcessedVar.update(_ + response.feedsProcessed) + hasMoreVar.set(response.hasMore) + Home.refreshUnreadCountBus.emit(()) + + case Failure(err) => + isLoadingVar.set(false) + hasErrorVar.set(true) + handleError(err) + } + + private val busySignal: Signal[(Boolean, List[String])] = + isLoadingSignal.combineWith(summariesSignal) + + private val footerSignal: Signal[(Boolean, List[String], Boolean, Boolean, Boolean)] = + isLoadingSignal.combineWith(summariesSignal, noFeedsSignal, hasMoreSignal, hasErrorSignal) + + def render: Element = + resetState() + val initialFetch = fetchSummaryBatch() + val settingsFetch = model.ensureSettingsLoaded() + div( cls := "main-content", - Panel( - _.headerText := "Summary", - BusyIndicator( - _.active <-- isLoading, - UList( - child <-- response - .splitStatus((resolved, _) => resolved.output, (pending, _) => "") - .map(unsafeParseToHtmlFragment(_)) - ) + initialFetch --> batchObserver, + settingsFetch.collectSuccess --> settingsVar.writer, + onMountBind { ctx => + loadMoreBus.events.flatMapSwitch { _ => + isLoadingVar.set(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 := "15px !important", + color := "var(--sapContent_LabelColor)", + lineHeight := "1.5", + child <-- busySignal.map { case (loading, summaries) => + if loading && 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 <-- noFeedsSignal.combineWith(funFactSignal).map { + case (true, funFact) => + val validFunFact = funFact.filter(f => + f.nonEmpty && !f.contains("All caught up") && !f.contains("No new feeds") + ) + 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." + ), + validFunFact + .map(fact => + 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) + ) + ) + .getOrElse(emptyNode) + ) + case _ => emptyNode + }, + div( + children <-- summariesSignal.map(summaries => + summaries.zipWithIndex.map { case (html, index) => + div( + unsafeParseToHtmlFragment(html), + if index < summaries.length - 1 then + hr( + marginTop.px := 20, + marginBottom.px := 20, + border := "none", + borderTop := "1px solid var(--sapContent_ForegroundBorderColor)" + ) + else emptyNode + ) + } + ) + ), + child <-- busySignal.map { case (loading, summaries) => + if loading && 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 <-- footerSignal.map { case (loading, summaries, noFeeds, hasMore, hasError) => + if !loading && summaries.nonEmpty && !noFeeds then + div( + paddingTop.px := 20, + display.flex, + flexDirection.column, + alignItems.center, + gap.px := 16, + Text( + s"${totalProcessedVar.now()} feeds summarized", + color := "var(--sapContent_LabelColor)" + ), + if hasMore && !hasError then + Button( + _.design := ButtonDesign.Emphasized, + _.icon := IconName.download, + "Load more news", + _.events.onClick.mapTo(()) --> loadMoreBus.writer + ) + else emptyNode + ) + else emptyNode + } ) ) ) - } -} 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..e04b07b 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..01b5d8d --- /dev/null +++ b/server/src/main/scala/ru/trett/rss/server/codecs/SummaryCodecs.scala @@ -0,0 +1,32 @@ +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..e62bf89 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 @@ -35,7 +35,11 @@ object UserController { SummaryLanguage.fromString(lang).map(_.displayName) } updatedUser = user.copy(settings = - User.Settings(settings.hideRead, validatedLanguage) + User.Settings( + settings.hideRead, + validatedLanguage, + settings.aiMode + ) ) result <- userService.updateUserSettings(updatedUser) _ <- logger.info( 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..9c2e754 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,8 @@ 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 + ) 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..d76d2e8 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 @@ -16,13 +16,14 @@ 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, 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]) @@ -33,22 +34,70 @@ 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 + uri"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:generateContent" + 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 - } + def getSummary(user: User, offset: Int): IO[SummaryResponse] = + for + totalUnread <- feedRepository.getTotalUnreadCount(user.id) + feeds <- feedRepository.getUnreadFeeds(user, batchSize, offset) + response <- + if feeds.isEmpty && offset == 0 then + // No feeds at all - generate fun fact + generateFunFact(user).map(funFact => + SummaryResponse( + result = SummarySuccess(""), + hasMore = false, + feedsProcessed = 0, + totalRemaining = 0, + noFeeds = true, + funFact = Some(funFact) + ) + ) + else if feeds.isEmpty then + // No more feeds (reached end of pagination) + IO.pure( + SummaryResponse( + result = SummarySuccess(""), + hasMore = false, + feedsProcessed = 0, + totalRemaining = 0, + noFeeds = false, + funFact = None + ) + ) + else + // Process feeds + for + text <- IO.pure(feeds.map(_.description).mkString("\n")) + strippedText <- IO.pure(Jsoup.parse(text).text()) + validatedLanguage = user.settings.summaryLanguage + .flatMap(SummaryLanguage.fromString) + .getOrElse(SummaryLanguage.English) + summaryResult <- summarize(strippedText, validatedLanguage.displayName) + // Mark feeds as read after successful summarization (only in AI mode) + isAiMode = !user.settings.aiMode.contains(false) + isSummarySuccess = summaryResult.isInstanceOf[SummarySuccess] + _ <- + if isAiMode && isSummarySuccess then + feedRepository.markFeedAsRead(feeds.map(_.link), user) + else IO.unit + remainingAfterThis = totalUnread - offset - feeds.size + yield SummaryResponse( + result = summaryResult, + hasMore = remainingAfterThis > 0, + feedsProcessed = feeds.size, + totalRemaining = Math.max(0, remainingAfterThis), + noFeeds = false, + funFact = None + ) + yield response + + private def generateFunFact(user: User): IO[String] = + val validatedLanguage = user.settings.summaryLanguage + .flatMap(SummaryLanguage.fromString) + .getOrElse(SummaryLanguage.English) - private def summarize(text: String, language: String): IO[String] = { val request = Request[IO]( method = Method.POST, uri = endpoint, @@ -56,15 +105,57 @@ class SummarizeService(feedRepository: FeedRepository, client: Client[IO], apiKe 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""" + Generate ONE short, interesting and surprising fun fact about technology, science, history, or nature. + Make it educational and fascinating - something that would make someone say "wow, I didn't know that!" + Keep it to 1-2 sentences maximum. + Respond in ${validatedLanguage.displayName}. + Do not use markdown formatting. + Do not add any introduction or preamble, just state the fact directly. + """ + ) + ) + ) + ) + ).asJson ) - .withEntity( - Map( - "contents" -> List( - Map( - "parts" -> List( - Map( - "text" -> - s""" + + client + .expect[GeminiResponse](request) + .map { response => + response.candidates.headOption + .flatMap(_.content.parts.flatMap(_.headOption)) + .map(_.text.trim) + .filter(_.nonEmpty) + .getOrElse("") + } + .handleErrorWith { error => + logger.error(error)(s"Error generating fun fact: $error") *> + IO.pure("") + } + + private def summarize(text: String, language: String): IO[SummaryResult] = + val request = Request[IO]( + method = Method.POST, + uri = endpoint, + 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. @@ -75,32 +166,39 @@ class SummarizeService(feedRepository: FeedRepository, client: Client[IO], apiKe 7. Use tags for important text. 8. Use tags for emphasized text. 9. Never use