From e049d2a9f4613fb45ad7d4fe35cabc7ba3751540 Mon Sep 17 00:00:00 2001 From: Bin Wang Date: Sun, 10 Nov 2024 11:41:03 -0500 Subject: [PATCH] Release v1.6.0 --- build.sbt | 2 +- js/css/main.css | 20 ++++++++ js/src/match-id.js | 3 ++ src/main/protobuf/grpc-api.proto | 29 +++++++++++ src/main/resources/application.conf | 3 +- .../scala/me/binwang/rss/cmd/Services.scala | 6 +-- .../dao/sql/ArticleEmbeddingTaskSqlDao.scala | 1 + .../me/binwang/rss/dao/sql/BaseSqlDao.scala | 8 +++ .../me/binwang/rss/dao/sql/SourceSqlDao.scala | 1 + .../me/binwang/rss/dao/sql/UserSqlDao.scala | 6 ++- .../rss/grpc/generator/GenerateGRPC.scala | 2 + .../scala/me/binwang/rss/llm/LLMModels.scala | 12 +++++ .../binwang/rss/llm/LargeLanguageModel.scala | 20 ++++---- .../scala/me/binwang/rss/llm/OpenAILLM.scala | 3 +- .../scala/me/binwang/rss/model/Errors.scala | 3 ++ .../scala/me/binwang/rss/model/User.scala | 18 ++++++- .../binwang/rss/service/ArticleService.scala | 17 +++++-- .../binwang/rss/service/SourceService.scala | 49 ++++++++++--------- .../me/binwang/rss/service/UserService.scala | 7 ++- .../rss/webview/routes/ArticleView.scala | 9 +++- .../webview/routes/RecommendationView.scala | 44 ++++++++++++++++- .../binwang/rss/webview/routes/UserView.scala | 29 +++++++++-- .../webview/widgets/EditFolderButton.scala | 19 ++++--- .../webview/widgets/EditSourceButton.scala | 30 +++++++----- .../rss/webview/widgets/SearchBox.scala | 34 +++++++------ .../rss/webview/widgets/TextWithIcon.scala | 13 +++++ .../rss/service/ArticleServiceSpec.scala | 10 ++-- 27 files changed, 304 insertions(+), 94 deletions(-) create mode 100644 src/main/scala/me/binwang/rss/llm/LLMModels.scala create mode 100644 src/main/scala/me/binwang/rss/webview/widgets/TextWithIcon.scala diff --git a/build.sbt b/build.sbt index 39f848a..976deef 100644 --- a/build.sbt +++ b/build.sbt @@ -120,7 +120,7 @@ libraryDependencies ++= Seq( "org.tpolecat" %% "doobie-specs2" % doobieVersion, "org.tpolecat" %% "doobie-hikari" % doobieVersion, "io.getquill" %% "quill-doobie" % "4.8.4", - "org.postgresql" % "postgresql" % "42.7.3", + "org.postgresql" % "postgresql" % "42.7.4", "io.getquill" %% "quill-cassandra-monix" % "4.8.4", // search diff --git a/js/css/main.css b/js/css/main.css index 26e2bd6..bc45e7d 100644 --- a/js/css/main.css +++ b/js/css/main.css @@ -940,6 +940,9 @@ popover-content a:hover { .search-options { width: 100%; font-size: 12px; + display: flex; + flex-direction: row; + flex-wrap: wrap; } .search-options * { @@ -952,6 +955,12 @@ popover-content a:hover { .search-options select { margin-right: 20px; + padding-right: 60px !important; +} + +.search-option { + display: flex; + flex-direction: row; } /* audio player */ @@ -1009,3 +1018,14 @@ popover-content a:hover { .nsfw-show .nsfw .article-desc-nsfw { display: none; } + +.text-with-icon { + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; +} + +.show-comment-btn { + font-size: 18px; +} \ No newline at end of file diff --git a/js/src/match-id.js b/js/src/match-id.js index 551c6a9..5ed7f1a 100644 --- a/js/src/match-id.js +++ b/js/src/match-id.js @@ -182,6 +182,9 @@ window.getPositionBefore = getPositionBefore; function getNextFolderPosition() { const folders = getFoldersFromDom(); + if (folders.length == 0) { + return POS_STEP; + } const lastFolder = folders[folders.length - 1]; return lastFolder.position + POS_STEP; } diff --git a/src/main/protobuf/grpc-api.proto b/src/main/protobuf/grpc-api.proto index 756d0db..4e2a46d 100644 --- a/src/main/protobuf/grpc-api.proto +++ b/src/main/protobuf/grpc-api.proto @@ -311,6 +311,8 @@ message User { bool subscribed = 16; NSFWSetting nsfwSetting = 17; SearchEngine searchEngine = 18; + optional LLMEngine llmEngine = 19; + optional string llmApiKey = 20; } // Define UserInfo @@ -328,6 +330,7 @@ message UserInfo { bool subscribed = 10; NSFWSetting nsfwSetting = 11; SearchEngine searchEngine = 12; + optional LLMEngine llmEngine = 13; } // Define UserUpdater @@ -368,6 +371,16 @@ message UserUpdater { optional string username = 13; optional NSFWSetting nsfwSetting = 14; optional SearchEngine searchEngine = 15; + + message LlmEngineOption { + optional LLMEngine llmEngineOption = 1; + } + optional LlmEngineOption llmEngine = 16; + + message LlmApiKeyOption { + optional string llmApiKeyOption = 1; + } + optional LlmApiKeyOption llmApiKey = 17; } // Define UserSession @@ -572,6 +585,12 @@ message ImportFailedSource { string error = 2; } +// Define LLMEngine + +enum LLMEngine { + OpenAI = 0; +} + // Define me.binwang.rss.service.ArticleService message GetArticlesBySourceRequest { @@ -1275,6 +1294,16 @@ message UpdateUserSettingsRequest { string token = 1; optional NSFWSetting nsfwSetting = 2; optional SearchEngine searchEngine = 3; + + message LlmEngineOption { + optional LLMEngine llmEngineOption = 1; + } + optional LlmEngineOption llmEngine = 4; + + message LlmApiKeyOption { + optional string llmApiKeyOption = 1; + } + optional LlmApiKeyOption llmApiKey = 5; } message RemoveCurrentFolderAndSourceRequest { diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index 0833f92..cf0c300 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -115,8 +115,7 @@ search { } open-ai { - apiKey = "" - model = "gpt-3.5-turbo" + model = "gpt-4o-mini" } image-proxy { diff --git a/src/main/scala/me/binwang/rss/cmd/Services.scala b/src/main/scala/me/binwang/rss/cmd/Services.scala index 15f8cc3..f62cd23 100644 --- a/src/main/scala/me/binwang/rss/cmd/Services.scala +++ b/src/main/scala/me/binwang/rss/cmd/Services.scala @@ -3,7 +3,7 @@ package me.binwang.rss.cmd import cats.effect.{IO, Resource} import cats.implicits._ import com.typesafe.config.ConfigFactory -import me.binwang.rss.llm.OpenAILLM +import me.binwang.rss.llm.{LLMModels, OpenAILLM} import me.binwang.rss.model.ImportLimit import me.binwang.rss.service._ import me.binwang.rss.sourcefinder.{HtmlSourceFinder, MultiSourceFinder, RegexSourceFinder} @@ -37,7 +37,7 @@ object Services { def apply(baseServer: BaseServer): Resource[IO, Services] = { val authorizer: Authorizer = new Authorizer(throttler, baseServer.userSessionDao, baseServer.folderDao) - val llm = new OpenAILLM(baseServer.sttpBackend) + val llmModels = LLMModels(openAI = new OpenAILLM(baseServer.sttpBackend)) val importLimit = ImportLimit( paidFolderCount = Try(config.getInt("import.limit.paid-user-folders")).toOption, @@ -54,7 +54,7 @@ object Services { new Services( new ArticleService(baseServer.articleDao, baseServer.articleContentDao, baseServer.articleUserMarkingDao, - baseServer.articleSearchDao, llm, authorizer), + baseServer.articleSearchDao, baseServer.userDao, llmModels, authorizer), new FolderService(baseServer.folderDao, baseServer.folderSourceDao, baseServer.sourceDao, baseServer.importSourcesTaskDao, authorizer, importLimit), new SourceService(baseServer.sourceDao, baseServer.folderSourceDao, baseServer.folderDao, baseServer.fetcher, diff --git a/src/main/scala/me/binwang/rss/dao/sql/ArticleEmbeddingTaskSqlDao.scala b/src/main/scala/me/binwang/rss/dao/sql/ArticleEmbeddingTaskSqlDao.scala index 2130239..a2e756f 100644 --- a/src/main/scala/me/binwang/rss/dao/sql/ArticleEmbeddingTaskSqlDao.scala +++ b/src/main/scala/me/binwang/rss/dao/sql/ArticleEmbeddingTaskSqlDao.scala @@ -52,6 +52,7 @@ class ArticleEmbeddingTaskSqlDao(implicit val connectionPool: ConnectionPool) ex .filter(_.status == lift(EmbeddingUpdateStatus.PENDING)) .sortBy(_.scheduledAt)(Ord.asc) .take(lift(size)) + .forUpdate() } val q = for { diff --git a/src/main/scala/me/binwang/rss/dao/sql/BaseSqlDao.scala b/src/main/scala/me/binwang/rss/dao/sql/BaseSqlDao.scala index fd1f59a..b27fb99 100644 --- a/src/main/scala/me/binwang/rss/dao/sql/BaseSqlDao.scala +++ b/src/main/scala/me/binwang/rss/dao/sql/BaseSqlDao.scala @@ -13,6 +13,7 @@ import me.binwang.rss.model.ArticleOrder.ArticleOrder import me.binwang.rss.model.EmbeddingUpdateStatus.EmbeddingUpdateStatus import me.binwang.rss.model.FetchStatus.FetchStatus import me.binwang.rss.model.ID.ID +import me.binwang.rss.model.LLMEngine.LLMEngine import me.binwang.rss.model.MoreLikeThisType.MoreLikeThisType import me.binwang.rss.model.NSFWSetting.NSFWSetting import me.binwang.rss.model._ @@ -95,6 +96,13 @@ trait BaseSqlDao { protected implicit val encodeNsfwSetting: MappedEncoding[NSFWSetting, String] = MappedEncoding[NSFWSetting, String](_.toString) + protected implicit val decodeLLMEngine: MappedEncoding[String, LLMEngine] = + MappedEncoding[String, LLMEngine](LLMEngine.withName) + + protected implicit val encodeLLMSetting: MappedEncoding[LLMEngine, String] = + MappedEncoding[LLMEngine, String](_.toString) + + protected implicit val mediaGroupsEncoder: Encoder[MediaGroups] = encoder(java.sql.Types.OTHER, (index, mediaGroups, row) => { val value = io.circe.syntax.EncoderOps(mediaGroups).asJson.toString() val pgObj = new PGobject() diff --git a/src/main/scala/me/binwang/rss/dao/sql/SourceSqlDao.scala b/src/main/scala/me/binwang/rss/dao/sql/SourceSqlDao.scala index e540de2..39dc650 100644 --- a/src/main/scala/me/binwang/rss/dao/sql/SourceSqlDao.scala +++ b/src/main/scala/me/binwang/rss/dao/sql/SourceSqlDao.scala @@ -107,6 +107,7 @@ class SourceSqlDao(implicit val connectionPool: ConnectionPool) extends SourceDa .filter(_.fetchStatus == lift(FetchStatus.SCHEDULED)) .sortBy(source => source.fetchScheduledAt) (Ord.asc) .take(lift(size)) + .forUpdate() } /* This doesn't work because it returns String instead of List[String] diff --git a/src/main/scala/me/binwang/rss/dao/sql/UserSqlDao.scala b/src/main/scala/me/binwang/rss/dao/sql/UserSqlDao.scala index ae0682e..09c2e19 100644 --- a/src/main/scala/me/binwang/rss/dao/sql/UserSqlDao.scala +++ b/src/main/scala/me/binwang/rss/dao/sql/UserSqlDao.scala @@ -34,7 +34,9 @@ class UserSqlDao(implicit val connectionPool: ConnectionPool) extends UserDao wi subscribeEndAt timestamp not null, subscribed boolean not null default false, nsfwSetting varchar not null default 'BLUR', - searchEngine jsonb not null + searchEngine jsonb not null, + llmEngine varchar default null, + llmApiKey varchar default null ) """) .update @@ -94,6 +96,8 @@ class UserSqlDao(implicit val connectionPool: ConnectionPool) extends UserDao wi setOpt(_.username, updater.username), setOpt(_.nsfwSetting, updater.nsfwSetting), setOpt(_.searchEngine, updater.searchEngine), + setOpt(_.llmEngine, updater.llmEngine), + setOpt(_.llmApiKey, updater.llmApiKey), ) run(q).transact(xa).map(_ > 0) } diff --git a/src/main/scala/me/binwang/rss/grpc/generator/GenerateGRPC.scala b/src/main/scala/me/binwang/rss/grpc/generator/GenerateGRPC.scala index c88d2af..28a94ac 100644 --- a/src/main/scala/me/binwang/rss/grpc/generator/GenerateGRPC.scala +++ b/src/main/scala/me/binwang/rss/grpc/generator/GenerateGRPC.scala @@ -7,6 +7,7 @@ import me.binwang.rss.model.ArticleListLayout.ArticleListLayout import me.binwang.rss.model.ArticleOrder.ArticleOrder import me.binwang.rss.model.FetchStatus.FetchStatus import me.binwang.rss.model.ID.ID +import me.binwang.rss.model.LLMEngine.LLMEngine import me.binwang.rss.model.MoreLikeThisType.MoreLikeThisType import me.binwang.rss.model.NSFWSetting.NSFWSetting import me.binwang.rss.model._ @@ -88,6 +89,7 @@ object GenerateGRPC extends GRPCGenerator { typeOf[SearchTerms], typeOf[ImportSourcesTask], typeOf[ImportFailedSource], + typeOf[LLMEngine], ) override val serviceClasses: Seq[Type] = Seq( diff --git a/src/main/scala/me/binwang/rss/llm/LLMModels.scala b/src/main/scala/me/binwang/rss/llm/LLMModels.scala new file mode 100644 index 0000000..cb69537 --- /dev/null +++ b/src/main/scala/me/binwang/rss/llm/LLMModels.scala @@ -0,0 +1,12 @@ +package me.binwang.rss.llm + +import me.binwang.rss.model.LLMEngine +import me.binwang.rss.model.LLMEngine.LLMEngine + +case class LLMModels( + openAI: OpenAILLM, +) { + def getModel(llmEngine: LLMEngine): LargeLanguageModel = llmEngine match { + case LLMEngine.OpenAI => openAI + } +} diff --git a/src/main/scala/me/binwang/rss/llm/LargeLanguageModel.scala b/src/main/scala/me/binwang/rss/llm/LargeLanguageModel.scala index 519bbaa..f334b8d 100644 --- a/src/main/scala/me/binwang/rss/llm/LargeLanguageModel.scala +++ b/src/main/scala/me/binwang/rss/llm/LargeLanguageModel.scala @@ -2,6 +2,7 @@ package me.binwang.rss.llm import cats.effect.IO import me.binwang.rss.llm.LargeLanguageModel.initMessage +import me.binwang.rss.model.Article import org.typelevel.log4cats.LoggerFactory case class ChatMessage( @@ -22,27 +23,26 @@ trait LargeLanguageModel { private val logger = LoggerFactory.getLoggerFromClass[IO](this.getClass) // return multiple choices based on previous message - def chat(messages: Seq[ChatMessage]): IO[Seq[ChatMessage]] + def chat(messages: Seq[ChatMessage], apiKey: String): IO[Seq[ChatMessage]] - def getRecommendSearchQueries(likedArticleTitles: Seq[String], size: Int): IO[Seq[String]] = { - // TODO: replace "videos" in prompt based on the type of articles + def getRecommendSearchQueries(articles: Seq[Article], size: Int, apiKey: String): IO[Seq[String]] = { val interestQueryStr = - """Here are some recent videos in a user's subscription feed, what is his interest? + """Here are some recent posts in a user's subscription feed, what is his interest? | Which languages the user speak?""".stripMargin + "\n\n" + - likedArticleTitles.map(t => s"* $t").mkString("\n") + articles.map(a => s"* ${a.title}, posted at ${a.postedAt.toLocalDateTime}").mkString("\n") val interestQuery = ChatMessage(role = "user", content = interestQueryStr) val initReq = Seq(initMessage, interestQuery) for { - interestChoices <- chat(initReq) + interestChoices <- chat(initReq, apiKey) recommendQueryStr = s"""Based on that, could you help me to come with $size search queries so that I can find more - | interesting videos for him/her on the Internet? Better to use all the languages the user speaks. - | Show only the search queries without any explanation. One query per line.""".stripMargin + | interesting articles or videos for him/her on the Internet? Better to use all the languages the user speaks. + | Show only the search queries without any explanation. One query per line. Do not include number or quotes""".stripMargin recommendQuery = ChatMessage(role = "user", content = recommendQueryStr) nextQueries = initReq :+ interestChoices.head :+ recommendQuery - recommendChoices <- chat(nextQueries) + recommendChoices <- chat(nextQueries, apiKey) // TODO: change to debug (or output to a seperate log file) after tuning results _ <- logger.info(s"LLM prompts and results for recommend search. Query: $nextQueries, result: $recommendChoices") - result = recommendChoices.head.content.split('\n').map(removeLeadingListNumber) + result = recommendChoices.head.content.split('\n').map(removeLeadingListNumber).filter(_.nonEmpty) } yield result } diff --git a/src/main/scala/me/binwang/rss/llm/OpenAILLM.scala b/src/main/scala/me/binwang/rss/llm/OpenAILLM.scala index e5ac5b9..a12a569 100644 --- a/src/main/scala/me/binwang/rss/llm/OpenAILLM.scala +++ b/src/main/scala/me/binwang/rss/llm/OpenAILLM.scala @@ -22,10 +22,9 @@ case class OpenAIChatResponse( class OpenAILLM(backend: SttpBackend[IO, _])(implicit val loggerFactory: LoggerFactory[IO]) extends LargeLanguageModel { - private val apiKey = ConfigFactory.load().getString("open-ai.apiKey") private val model = ConfigFactory.load().getString("open-ai.model") - override def chat(messages: Seq[ChatMessage]): IO[Seq[ChatMessage]] = { + override def chat(messages: Seq[ChatMessage], apiKey: String): IO[Seq[ChatMessage]] = { val req = OpenAIChatRequest(model, messages) basicRequest .post(uri"https://api.openai.com/v1/chat/completions") diff --git a/src/main/scala/me/binwang/rss/model/Errors.scala b/src/main/scala/me/binwang/rss/model/Errors.scala index ef70e03..8761f16 100644 --- a/src/main/scala/me/binwang/rss/model/Errors.scala +++ b/src/main/scala/me/binwang/rss/model/Errors.scala @@ -55,6 +55,9 @@ final case class UserCannotBeActivated(email: String) final case class UserDeleteCodeInvalidException(code: String) extends ServerException(code = 20009, msg = s"User delete verification code $code is not valid") +final case class LLMEngineNotConfigured(userID: String) + extends ServerException(code = 20010, msg = s"LLM engine not configured for user $userID") + // Source service final case class SourceNotFound(sourceID: String) diff --git a/src/main/scala/me/binwang/rss/model/User.scala b/src/main/scala/me/binwang/rss/model/User.scala index c810ffb..c47cdbf 100644 --- a/src/main/scala/me/binwang/rss/model/User.scala +++ b/src/main/scala/me/binwang/rss/model/User.scala @@ -1,8 +1,9 @@ package me.binwang.rss.model +import me.binwang.rss.model.LLMEngine.LLMEngine + import java.time.ZonedDateTime import java.util.UUID - import me.binwang.rss.model.NSFWSetting.NSFWSetting object NSFWSetting extends Enumeration { @@ -14,6 +15,13 @@ object NSFWSetting extends Enumeration { = Value } +object LLMEngine extends Enumeration { + type LLMEngine = Value + val + OpenAI + = Value +} + case class SearchEngine(name: Option[String], urlPrefix: String) object SearchEngine { @@ -45,6 +53,8 @@ case class User ( subscribed: Boolean = false, nsfwSetting: NSFWSetting = NSFWSetting.BLUR, searchEngine: SearchEngine = SearchEngine.DEFAULT, + llmEngine: Option[LLMEngine] = None, + llmApiKey: Option[String] = None, ) { def toInfo: UserInfo = { @@ -61,6 +71,7 @@ case class User ( subscribed = subscribed, nsfwSetting = nsfwSetting, searchEngine = searchEngine, + llmEngine = llmEngine, ) } @@ -78,7 +89,8 @@ case class UserInfo ( currentSourceID: Option[String] = None, subscribed: Boolean = false, nsfwSetting: NSFWSetting = NSFWSetting.BLUR, - searchEngine: SearchEngine = SearchEngine.DUCKDUCKGO, + searchEngine: SearchEngine = SearchEngine.DEFAULT, + llmEngine: Option[LLMEngine] = None, ) case class UserUpdater ( @@ -97,4 +109,6 @@ case class UserUpdater ( username: Option[String] = None, nsfwSetting: Option[NSFWSetting] = None, searchEngine: Option[SearchEngine] = None, + llmEngine: Option[Option[LLMEngine]] = None, + llmApiKey: Option[Option[String]] = None, ) diff --git a/src/main/scala/me/binwang/rss/service/ArticleService.scala b/src/main/scala/me/binwang/rss/service/ArticleService.scala index 349a9cc..f846aea 100644 --- a/src/main/scala/me/binwang/rss/service/ArticleService.scala +++ b/src/main/scala/me/binwang/rss/service/ArticleService.scala @@ -2,8 +2,8 @@ package me.binwang.rss.service import cats.effect.{Clock, IO} import me.binwang.archmage.core.CatsMacros.timed -import me.binwang.rss.dao.{ArticleContentDao, ArticleDao, ArticleSearchDao, ArticleUserMarkingDao} -import me.binwang.rss.llm.LargeLanguageModel +import me.binwang.rss.dao._ +import me.binwang.rss.llm.LLMModels import me.binwang.rss.metric.TimeMetrics import me.binwang.rss.model.ID.ID import me.binwang.rss.model._ @@ -15,7 +15,8 @@ class ArticleService( private val articleContentDao: ArticleContentDao, private val articleUserMarkingDao: ArticleUserMarkingDao, private val articleSearchDao: ArticleSearchDao, - private val llm: LargeLanguageModel, + private val userDao: UserDao, + private val llmModels: LLMModels, private implicit val authorizer: Authorizer, ) extends TimeMetrics { @@ -167,6 +168,12 @@ class ArticleService( likedArticlesPostedAfter: ZonedDateTime, resultSize: Int): IO[SearchTerms] = timed { for { session <- authorizer.checkFolderPermission(token, folderID).map(_._1) + user <- userDao.getByID(session.userID) + llmApiKey = user.flatMap(_.llmApiKey) + llmModel = user.flatMap(_.llmEngine).map(llmModels.getModel) + _ <- if (llmApiKey.isEmpty || llmModel.isEmpty) { + IO.raiseError(LLMEngineNotConfigured(user.map(_.id).getOrElse(""))) + } else IO.pure() nowInstant <- Clock[IO].realTimeInstant now = ZonedDateTime.ofInstant(nowInstant, ZoneId.systemDefault()) recentLiked <- articleDao.listByFolderWithUserMarking(folderID, articleSize, @@ -179,8 +186,8 @@ class ArticleService( articleDao.listByFolderWithUserMarking(folderID, articleSize - recentLiked.size, now, "", session.userID, bookmarked = Some(false)).compile.toList } - titles = (recentLiked ++ moreArticles).map(_.article.title) - searches <- llm.getRecommendSearchQueries(titles, resultSize) + articles = (recentLiked ++ moreArticles).map(_.article) + searches <- llmModel.get.getRecommendSearchQueries(articles, resultSize, llmApiKey.get) } yield SearchTerms(searches) } diff --git a/src/main/scala/me/binwang/rss/service/SourceService.scala b/src/main/scala/me/binwang/rss/service/SourceService.scala index 3ae95ed..abcf0af 100644 --- a/src/main/scala/me/binwang/rss/service/SourceService.scala +++ b/src/main/scala/me/binwang/rss/service/SourceService.scala @@ -1,6 +1,7 @@ package me.binwang.rss.service import cats.effect.{Clock, IO} +import cats.implicits._ import me.binwang.archmage.core.CatsMacros.timed import me.binwang.rss.dao.{FolderDao, FolderSourceDao, SourceDao} import me.binwang.rss.fetch.fetcher.BackgroundFetcher @@ -218,35 +219,39 @@ class SourceService( } def findSource(token: String, url: String): fs2.Stream[IO, SourceResult] = timed { - authorizer.authorizeAsStream(token).flatMap( _ => + authorizer.authorizeAsStream(token).flatMap(_ => seqToStream(sourceFinder.findSource(url))) } def replaceSourceInstance(token: String, oldInstance: String, newInstance: String, size: Int): IO[Int] = timed { authorizer.authorize(token).flatMap { session => - folderSourceDao.getSourcesByUser(session.userID, size).evalMap { folderSource => - val oldUrl = folderSource.source.xmlUrl - val newUrl = oldUrl.replace(oldInstance, newInstance) - if (newUrl.equals(oldUrl)) { - IO.pure(None) - } else { - (for { - _ <- logger.info(s"Try to verify source from $newUrl for replacing instance") - source <- getOrImportSourceInner(newUrl) - _ <- logger.info(s"Verified source from $newUrl for replacing instance") - _ <- logger.info(s"Adding source ${source.id} from $newUrl to folder ${folderSource.folderMapping.folderID}") - _ <- folderSourceDao.addSourceToFolder(folderSource.folderMapping.copy(sourceID = source.id)) - _ <- logger.info(s"Added source ${source.id} from $newUrl to folder ${folderSource.folderMapping.folderID}") - _ <- logger.info(s"Deleting source ${folderSource.source.id} from ${folderSource.source.xmlUrl} " + - s"from folder ${folderSource.folderMapping.folderID}") - _ <- folderSourceDao.delSourceFromFolder(folderSource.folderMapping.folderID, folderSource.source.id) - _ <- logger.info(s"Deleted source ${folderSource.source.id} from ${folderSource.source.xmlUrl} " + - s"from folder ${folderSource.folderMapping.folderID}") - } yield Some()).handleErrorWith { err => - logger.error(err)(s"Error when replace $oldInstance with $newInstance").map(_ => None) + folderSourceDao.getSourcesByUser(session.userID, size).compile.toList.flatMap { + _.map { folderSource => + val oldUrl = folderSource.source.xmlUrl + val newUrl = oldUrl.replace(oldInstance, newInstance) + if (newUrl.equals(oldUrl)) { + IO.pure(None) + } else { + (for { + _ <- logger.info(s"Try to verify source from $newUrl for replacing instance") + source <- getOrImportSourceInner(newUrl) + _ <- logger.info(s"Verified source from $newUrl for replacing instance") + _ <- logger.info(s"Adding source ${source.id} from $newUrl to folder ${folderSource.folderMapping.folderID}") + _ <- folderSourceDao.addSourceToFolder(folderSource.folderMapping.copy(sourceID = source.id)) + _ <- logger.info(s"Added source ${source.id} from $newUrl to folder ${folderSource.folderMapping.folderID}") + _ <- logger.info(s"Deleting source ${folderSource.source.id} from ${folderSource.source.xmlUrl} " + + s"from folder ${folderSource.folderMapping.folderID}") + _ <- folderSourceDao.delSourceFromFolder(folderSource.folderMapping.folderID, folderSource.source.id) + _ <- logger.info(s"Deleted source ${folderSource.source.id} from ${folderSource.source.xmlUrl} " + + s"from folder ${folderSource.folderMapping.folderID}") + } yield Some()).handleErrorWith { err => + logger.error(err)(s"Error when replace $oldInstance with $newInstance").map(_ => None) + } } + }.sequence.map { + _.count(_.isDefined) } - }.filter(_.isDefined).fold(0)((count, _) => count + 1).compile.last.map(_.getOrElse(0)) + } } } diff --git a/src/main/scala/me/binwang/rss/service/UserService.scala b/src/main/scala/me/binwang/rss/service/UserService.scala index 019c6e7..ce64fde 100644 --- a/src/main/scala/me/binwang/rss/service/UserService.scala +++ b/src/main/scala/me/binwang/rss/service/UserService.scala @@ -7,6 +7,7 @@ import me.binwang.archmage.core.CatsMacros.timed import me.binwang.rss.dao._ import me.binwang.rss.mail.MailSender import me.binwang.rss.metric.TimeMetrics +import me.binwang.rss.model.LLMEngine.LLMEngine import me.binwang.rss.model.NSFWSetting.NSFWSetting import me.binwang.rss.model._ import me.binwang.rss.service.UserService._ @@ -273,13 +274,15 @@ class UserService( } } - def updateUserSettings(token: String, - nsfwSetting: Option[NSFWSetting], searchEngine: Option[SearchEngine]): IO[Unit] = timed { + def updateUserSettings(token: String, nsfwSetting: Option[NSFWSetting], searchEngine: Option[SearchEngine], + llmEngine: Option[Option[LLMEngine]], llmApiKey: Option[Option[String]]): IO[Unit] = timed { authorizer.authorize(token).flatMap { userSession => userDao .update(userSession.userID, UserUpdater( nsfwSetting = nsfwSetting, searchEngine = searchEngine, + llmEngine = llmEngine, + llmApiKey = llmApiKey, )) .map(_ => ()) } diff --git a/src/main/scala/me/binwang/rss/webview/routes/ArticleView.scala b/src/main/scala/me/binwang/rss/webview/routes/ArticleView.scala index 70608c7..3cfd5d5 100644 --- a/src/main/scala/me/binwang/rss/webview/routes/ArticleView.scala +++ b/src/main/scala/me/binwang/rss/webview/routes/ArticleView.scala @@ -33,12 +33,19 @@ class ArticleView(articleService: ArticleService) extends Http4sView with Scalat ArticleRender.articleAttrs(articleWithMarking.userMarking, bindReadClass = false), div( id := "article-reader", + xData := "{showComments: true}", div(cls := "article-title")(article.article.title), ArticleRender.renderInfo(article.article, req.params.get("in_folder")), ArticleRender.mediaDom(article.article, ArticleRender.mediaRenderOptionInReader), div(cls := "article-content")(raw(article.content.validHtml)), ArticleRender.renderOps(article.article, showActionable = false), - tag("somment-comment")(attr("link") := article.article.link), + if (article.article.comments.getOrElse(0) > 0) { Seq( + a(nullHref, cls := "comment-op show-comment-btn", xShow := "showComments", + xOnClick := "showComments = false", "Hide comments"), + a(nullHref, cls := "comment-op show-comment-btn", xShow := "!showComments", + xOnClick := "showComments = true", "Show comments"), + tag("somment-comment")(xShow := "showComments", attr("link") := article.article.link), + )} else "", div( id := "recommendation-sections", div(hxTrigger := "intersect once", hxTarget := "this", hxSwap := "outerHTML", diff --git a/src/main/scala/me/binwang/rss/webview/routes/RecommendationView.scala b/src/main/scala/me/binwang/rss/webview/routes/RecommendationView.scala index 43454aa..28a1e26 100644 --- a/src/main/scala/me/binwang/rss/webview/routes/RecommendationView.scala +++ b/src/main/scala/me/binwang/rss/webview/routes/RecommendationView.scala @@ -1,7 +1,7 @@ package me.binwang.rss.webview.routes import cats.effect._ import me.binwang.rss.grpc.ModelTranslator -import me.binwang.rss.model.MoreLikeThisType +import me.binwang.rss.model.{MoreLikeThisType, UserInfo} import me.binwang.rss.service.{ArticleService, FolderService, MoreLikeThisService, SourceService, UserService} import me.binwang.rss.webview.auth.CookieGetter.reqToCookieGetter import me.binwang.rss.webview.basic.ContentRender.{hxSwapContentAttrs, wrapContentRaw} @@ -19,6 +19,7 @@ import scalatags.Text.all._ import java.net.URLEncoder import java.nio.charset.StandardCharsets +import java.time.{ZoneId, ZonedDateTime} class RecommendationView(moreLikeThisService: MoreLikeThisService, articleService: ArticleService, sourceService: SourceService, folderService: FolderService, userService: UserService, @@ -267,9 +268,48 @@ class RecommendationView(moreLikeThisService: MoreLikeThisService, articleServic userService.getMyUserInfo(token).flatMap { user => articleService.getArticleTermVector(token, articleID, 10).flatMap { terms => val searchTerm = terms.terms.map(_.term).mkString(" ") - val url = user.searchEngine.urlPrefix + URLEncoder.encode(searchTerm, StandardCharsets.UTF_8) + val url = externalSearchUrl(user, searchTerm) HttpResponse.redirect("", url, req) } } + + case req @ GET -> Root / "folders" / folderID / "external-recommend" => wrapContentRaw(req) { + val token = req.authToken + for { + now <- Clock[IO].realTimeInstant + postAfter = ZonedDateTime.ofInstant(now, ZoneId.systemDefault()).minusWeeks(4) + user <- userService.getMyUserInfo(token) + links <- articleService.getFolderRecommendSearchTerms(token, folderID, 10, postAfter, 5) + .map(Some(_)).handleError(_ => None) + content: Frag = if (links.nonEmpty) { + label(cls := "external-recommend-hint", "Choose one to search in external search engine") +: + links.get.terms.map { term => + a( + cls := "external-recommend-link", + href := externalSearchUrl(user, term), + target := "_blank", + term, + ) + } + } else { + div("LLM (large language model) is not configured. Go to ", + a(nullHref, hxGet := "/settings", hxPushUrl := "true", hxSwapContentAttrs, "settings"), + " to configure it." + ) + } + dom = Seq( + PageHeader(Some("External Recommendation")), + div( + cls := "form-body form-start", + content, + ) + ) + res <- Ok(dom, `Content-Type`(MediaType.text.html)) + } yield res + } + } + + private def externalSearchUrl(user: UserInfo, term: String): String = { + user.searchEngine.urlPrefix + URLEncoder.encode(term, StandardCharsets.UTF_8) } } diff --git a/src/main/scala/me/binwang/rss/webview/routes/UserView.scala b/src/main/scala/me/binwang/rss/webview/routes/UserView.scala index fb0a288..d51b992 100644 --- a/src/main/scala/me/binwang/rss/webview/routes/UserView.scala +++ b/src/main/scala/me/binwang/rss/webview/routes/UserView.scala @@ -1,6 +1,6 @@ package me.binwang.rss.webview.routes import cats.effect.IO -import me.binwang.rss.model.{NSFWSetting, SearchEngine} +import me.binwang.rss.model.{LLMEngine, NSFWSetting, SearchEngine} import me.binwang.rss.service.{SystemService, UserService} import me.binwang.rss.webview.auth.CookieGetter.reqToCookieGetter import me.binwang.rss.webview.basic.ContentRender.wrapContentRaw @@ -32,8 +32,14 @@ class UserView(userService: UserService, systemService: SystemService) extends H div( cls := "form-body", div( - "If you have any feedback or question, feel free to contact us at ", - a(href := s"mailto:$customerServiceEmail")(customerServiceEmail), ".", + p( + "If you encounter any bug, report it to ", + a(href := "https://github.com/wb14123/rss_brain_release/issues", target := "_blank")("Github issue tracker"), ".", + ), + p( + "If you have any feedback or question, feel free to contact us at ", + a(href := s"mailto:$customerServiceEmail")(customerServiceEmail), ".", + ), ) ) ) @@ -51,9 +57,12 @@ class UserView(userService: UserService, systemService: SystemService) extends H urlPrefix = data.values.get("search-engine-url").flatMap(_.headOption).get) ) + val llmEngine = data.values.get("llm-engine").flatMap(_.headOption.filter(_.nonEmpty)).map(LLMEngine.withName) + val llmApiKey = data.values.get("llm-api-key").flatMap(_.headOption.filter(_.nonEmpty)) + userService.updateUserSettings(token = token, nsfwSetting = data.values.get("nsfw-setting").map(s => NSFWSetting.withName(s.headOption.get)), - searchEngine = Some(searchEngine), + searchEngine = Some(searchEngine), Some(llmEngine), llmApiKey.map(Some(_)), ).flatMap(_ => HttpResponse.redirect("", "/settings", req)) } @@ -97,6 +106,8 @@ class UserView(userService: UserService, systemService: SystemService) extends H searchEngineName: '${user.searchEngine.name.getOrElse("")}', searchEngineUrl: '${user.searchEngine.urlPrefix}', nsfwSetting: '${user.nsfwSetting.toString}', + llmEngine: '${user.llmEngine.map(_.toString).getOrElse("")}', + llmApiKey: '', }""", cls := "form-section", h2("System"), @@ -124,6 +135,16 @@ class UserView(userService: UserService, systemService: SystemService) extends H label("Search Engine URL Prefix"), input(name := "search-engine-url", xModel := "searchEngineUrl", `type` := "text", xBind("disabled") := "searchEngineName !== ''"), + label("LLM Engine"), + select( + name := "llm-engine", + xModel := "llmEngine", + option("Disable", value := ""), + option("OpenAI", value := "OpenAI"), + ), + label("LLM API Key (Hidden once saved)"), + input(name := "llm-api-key", xModel := "llmApiKey", `type` := "text", + xBind("disabled") := "llmEngine == ''"), label("NSFW Content Display"), select( name := "nsfw-setting", diff --git a/src/main/scala/me/binwang/rss/webview/widgets/EditFolderButton.scala b/src/main/scala/me/binwang/rss/webview/widgets/EditFolderButton.scala index 997bbb7..beedc0a 100644 --- a/src/main/scala/me/binwang/rss/webview/widgets/EditFolderButton.scala +++ b/src/main/scala/me/binwang/rss/webview/widgets/EditFolderButton.scala @@ -11,16 +11,18 @@ object EditFolderButton { xFor := "f in folders", attr(":key") := "f.id", a(nullHref, xText := "f.name", - xOnClick := s"updateFolderPosition('$folderID', await $getPositionFunc(f.id)); $$refs.folderEditMenu.closePopover();") + xBind("class") := "f.disabled ? 'isDisabled' : ''", + xOnClick := s"if (!f.disabled) {updateFolderPosition('$folderID', await $getPositionFunc(f.id));} $$refs.folderEditMenu.closePopover();") ) } - private def folderMovingMenu(folderID: String, text: String, getPositionJSFunc: String): Frag = { + private def folderMovingMenu(folderID: String, text: Frag, getPositionJSFunc: String): Frag = { + val checkLengthJs = "if (folders.length === 0) folders = [{name: 'No other folder', disabled: true}];" popoverMenu( PopoverMenu.subMenuAttrs, a(cls := "folder-move-menu-button", nullHref, text), xData := "{folders: []}", - xOn("popover-opened") := s"folders = getFoldersFromDom('$folderID', false)", + xOn("popover-opened") := s"folders = getFoldersFromDom('$folderID', false) ; $checkLengthJs", popoverContent( zIndex := "11", cls := "folder-select-menu", @@ -36,12 +38,15 @@ object EditFolderButton { popoverContent( cls := "folder-op-menu", zIndex := "10", - folderMovingMenu(folderID, "Move after folder ...", "getPositionAfter"), - folderMovingMenu(folderID, "Move before folder ...", "getPositionBefore"), - a(nullHref, "Edit folder", hxGet := s"/folders/$folderID/edit", hxSwapContentAttrs, hxPushUrl := "true", + folderMovingMenu(folderID, TextWithIcon("north_west", "Move before folder ..."), "getPositionBefore"), + folderMovingMenu(folderID, TextWithIcon("south_west", "Move after folder ..."), "getPositionAfter"), + a(nullHref, TextWithIcon("settings", "Folder settings"), + hxGet := s"/folders/$folderID/edit", hxSwapContentAttrs, hxPushUrl := "true", xOnClick := "$refs.folderEditMenu.closePopover()"), - a(nullHref, "Delete folder", hxDelete := s"/hx/folders/$folderID", + a(nullHref, TextWithIcon("delete", "Delete folder"), hxDelete := s"/hx/folders/$folderID", xOnClick := "$refs.folderEditMenu.closePopover()"), + a(nullHref, TextWithIcon("public", "Recommendations"), + hxGet := s"/folders/$folderID/external-recommend", hxPushUrl := "true", hxSwapContentAttrs) ) ) diff --git a/src/main/scala/me/binwang/rss/webview/widgets/EditSourceButton.scala b/src/main/scala/me/binwang/rss/webview/widgets/EditSourceButton.scala index 40616b5..ca6eaaf 100644 --- a/src/main/scala/me/binwang/rss/webview/widgets/EditSourceButton.scala +++ b/src/main/scala/me/binwang/rss/webview/widgets/EditSourceButton.scala @@ -6,12 +6,13 @@ import scalatags.Text.all._ object EditSourceButton { - private def folderMovingMenu(sourceID: String, folderID: String, text: String, func: String): Frag = { + private def folderMovingMenu(sourceID: String, folderID: String, text: Frag, func: String): Frag = { + val checkLengthJs = "if (folders.length === 0) folders = [{name: 'No other folder', disabled: true}];" popoverMenu( PopoverMenu.subMenuAttrs, a(cls := "folder-move-menu-button", nullHref, text), xData := "{folders: []}", - xOn("popover-opened") := s"folders = getFoldersFromDom('$folderID', true)", + xOn("popover-opened") := s"folders = getFoldersFromDom('$folderID', true) ; $checkLengthJs", popoverContent( zIndex := "11", cls := "folder-select-menu", @@ -19,13 +20,14 @@ object EditSourceButton { xFor := "f in folders", attr(":key") := "f.id", a(nullHref, xText := "f.name", - xOnClick := s"$func('$sourceID', '$folderID', f.id, f.nextPosition); $$refs.folderEditMenu.closePopover();") + xBind("class") := "f.disabled ? 'isDisabled' : ''", + xOnClick := s"if (!f.disabled) {$func('$sourceID', '$folderID', f.id, f.nextPosition);} $$refs.folderEditMenu.closePopover();") ) ), ) } - private def sourceMovingMenu(sourceID: String, folderID: String, text: String, func: String): Frag = { + private def sourceMovingMenu(sourceID: String, folderID: String, text: Frag, func: String): Frag = { val checkLengthJs = "if (sources.length === 0) sources = [{name: 'No other feed', disabled: true}];" popoverMenu( PopoverMenu.subMenuAttrs, @@ -40,7 +42,7 @@ object EditSourceButton { attr(":key") := "s.id", a(nullHref, xText := "s.name", xBind("class") := "s.disabled ? 'isDisabled' : ''", - xOnClick := s"$func('$folderID', '$sourceID', s.id) ; $$refs.folderEditMenu.closePopover(); ") + xOnClick := s"if (!s.disabled) {$func('$folderID', '$sourceID', s.id);} $$refs.folderEditMenu.closePopover(); ") ) ) ) @@ -54,15 +56,17 @@ object EditSourceButton { popoverContent( cls := "folder-op-menu", zIndex := "10", - folderMovingMenu(sourceID, folderID, "Move to folder ...", "moveSourceToFolder"), - folderMovingMenu(sourceID, folderID, "Copy to folder ...", "copySourceToFolder"), - sourceMovingMenu(sourceID, folderID, "Move before feed ...", "moveSourceBefore"), - sourceMovingMenu(sourceID, folderID, "Move after feed ...", "moveSourceAfter"), - a(nullHref, "Edit feed", hxGet := s"/folders/$folderID/sources/$sourceID/edit", hxSwapContentAttrs, hxPushUrl := "true", - xOnClick := "$refs.folderEditMenu.closePopover()"), - a(nullHref, "Delete from folder", + folderMovingMenu(sourceID, folderID, TextWithIcon("content_cut", "Move to folder ..."), "moveSourceToFolder"), + folderMovingMenu(sourceID, folderID, TextWithIcon("content_copy", "Copy to folder ..."), "copySourceToFolder"), + sourceMovingMenu(sourceID, folderID, TextWithIcon("north_west", "Move before feed ..."), "moveSourceBefore"), + sourceMovingMenu(sourceID, folderID, TextWithIcon("south_west", "Move after feed ..."), "moveSourceAfter"), + a(nullHref, TextWithIcon("settings", "Feed settings"), + hxGet := s"/folders/$folderID/sources/$sourceID/edit", + hxSwapContentAttrs, hxPushUrl := "true", xOnClick := "$refs.folderEditMenu.closePopover()"), + a(nullHref, TextWithIcon("delete", "Delete from folder"), xOnClick := s"deleteSourceFromFolder('$sourceID', '$folderID') ; $$refs.folderEditMenu.closePopover()"), - a(nullHref, "Unsubscribe", xOnClick := s"unsubscribeSource('$sourceID'); $$refs.folderEditMenu.closePopover()"), + a(nullHref, TextWithIcon("delete_forever", "Unsubscribe"), + xOnClick := s"unsubscribeSource('$sourceID'); $$refs.folderEditMenu.closePopover()"), ) ) } diff --git a/src/main/scala/me/binwang/rss/webview/widgets/SearchBox.scala b/src/main/scala/me/binwang/rss/webview/widgets/SearchBox.scala index 0d11af2..5e248fe 100644 --- a/src/main/scala/me/binwang/rss/webview/widgets/SearchBox.scala +++ b/src/main/scala/me/binwang/rss/webview/widgets/SearchBox.scala @@ -27,21 +27,27 @@ object SearchBox { ), div( cls := "form-row search-options", - label("Order By"), - select( - name := "by_time", - option(value := "false", "Most relevant first", if (!searchOptions.exists(_.sortByTime)) selected else ""), - option(value := "true", "Most recent first", if (searchOptions.exists(_.sortByTime)) selected else ""), + div( + cls := "search-option", + label("Order By"), + select( + name := "by_time", + option(value := "false", "Most relevant first", if (!searchOptions.exists(_.sortByTime)) selected else ""), + option(value := "true", "Most recent first", if (searchOptions.exists(_.sortByTime)) selected else ""), + ), + ), + div( + cls := "search-option", + label("Time"), + select( + name := "time_range", + option(value := "", "All time"), + option(value := daySeconds, "Past 24 hours", timeSelected(daySeconds, timeRangeOpt)), + option(value := weekSeconds, "Past week", timeSelected(weekSeconds, timeRangeOpt)), + option(value := monthSeconds, "Past month", timeSelected(monthSeconds, timeRangeOpt)), + option(value := yearSeconds, "Past year", timeSelected(yearSeconds, timeRangeOpt)), + ) ), - label("Time"), - select( - name := "time_range", - option(value := "", "All time"), - option(value := daySeconds, "Past 24 hours", timeSelected(daySeconds, timeRangeOpt)), - option(value := weekSeconds, "Past week", timeSelected(weekSeconds, timeRangeOpt)), - option(value := monthSeconds, "Past month", timeSelected(monthSeconds, timeRangeOpt)), - option(value := yearSeconds, "Past year", timeSelected(yearSeconds, timeRangeOpt)), - ) ), ) } diff --git a/src/main/scala/me/binwang/rss/webview/widgets/TextWithIcon.scala b/src/main/scala/me/binwang/rss/webview/widgets/TextWithIcon.scala new file mode 100644 index 0000000..19d3265 --- /dev/null +++ b/src/main/scala/me/binwang/rss/webview/widgets/TextWithIcon.scala @@ -0,0 +1,13 @@ +package me.binwang.rss.webview.widgets + + +import me.binwang.rss.webview.basic.ScalaTagAttributes._ +import scalatags.Text.all._ + +object TextWithIcon { + + def apply(icon: String, text: String): Frag = { + span(cls := "text-with-icon", iconSpan(icon), text) + } + +} diff --git a/src/test/scala/me/binwang/rss/service/ArticleServiceSpec.scala b/src/test/scala/me/binwang/rss/service/ArticleServiceSpec.scala index bdeb100..24c4dd2 100644 --- a/src/test/scala/me/binwang/rss/service/ArticleServiceSpec.scala +++ b/src/test/scala/me/binwang/rss/service/ArticleServiceSpec.scala @@ -10,7 +10,7 @@ import me.binwang.rss.dao.sql._ import me.binwang.rss.dao.{ArticleContentDao, ArticleDao} import me.binwang.rss.generator.ConnectionPoolManager.connectionPool import me.binwang.rss.generator.{ArticleContents, Articles, Folders} -import me.binwang.rss.llm.OpenAILLM +import me.binwang.rss.llm.{LLMModels, OpenAILLM} import me.binwang.rss.model._ import me.binwang.rss.util.Throttler import org.scalatest.funspec.AnyFunSpec @@ -46,11 +46,12 @@ class ArticleServiceSpec extends AnyFunSpec with BeforeAndAfterEach with BeforeA private implicit val userSessionDao: UserSessionSqlDao = new UserSessionSqlDao() private implicit val folderDao: FolderSqlDao = new FolderSqlDao() private implicit val folderSourceDao: FolderSourceSqlDao = new FolderSourceSqlDao() + private implicit val userDao: UserSqlDao = new UserSqlDao() private val articleSearchDao = new ArticleSearchElasticDao(1.0, 0.0) private implicit val authorizer: Authorizer = new Authorizer(Throttler(), userSessionDao, folderDao) - private val llm = new OpenAILLM(sttpBackend) + private val llm = LLMModels(openAI = new OpenAILLM(sttpBackend)) private val articleService = new ArticleService(articleDao, articleContentDao, articleUserMarkingDao, - articleSearchDao, llm, authorizer) + articleSearchDao, userDao, llm, authorizer) private val token = UUID.randomUUID().toString private val userID = UUID.randomUUID().toString @@ -68,6 +69,8 @@ class ArticleServiceSpec extends AnyFunSpec with BeforeAndAfterEach with BeforeA _ <- folderSourceDao.createTable() _ <- articleUserMarkingDao.dropTable() _ <- articleUserMarkingDao.createTable() + _ <- userDao.dropTable() + _ <- userDao.createTable() } yield () createTables.unsafeRunSync() } @@ -80,6 +83,7 @@ class ArticleServiceSpec extends AnyFunSpec with BeforeAndAfterEach with BeforeA _ <- folderDao.deleteAll() _ <- folderSourceDao.deleteAll() _ <- articleUserMarkingDao.deleteAll() + _ <- userDao.deleteAll() } yield () clearTables.unsafeRunSync()