mirror of
https://github.com/lichess-org/lila.git
synced 2026-05-26 13:51:00 +00:00
moderate images
This commit is contained in:
@@ -195,6 +195,10 @@ final class LilaComponents(
|
||||
private val teamRouter: _root_.router.team.Routes = wire[_root_.router.team.Routes]
|
||||
val router: Router = wire[_root_.router.router.Routes]
|
||||
|
||||
// i'll leave this unseemly var injection here (in the most obnoxious place)
|
||||
// and pay attention to how you fix it. burn after reading
|
||||
lila.ui.bits.setPicfitOrigin(env.memo.picfitUrl.origin)
|
||||
|
||||
lila.common.Uptime.startedAt
|
||||
UiEnv.setEnv(env)
|
||||
|
||||
|
||||
@@ -36,10 +36,12 @@ final class Dev(env: Env) extends LilaController(env):
|
||||
env.relay.proxyDomainRegex,
|
||||
env.relay.proxyHostPort,
|
||||
env.relay.proxyCredentials,
|
||||
env.report.api.modelSetting,
|
||||
env.report.api.promptSetting,
|
||||
env.ublog.automod.modelSetting,
|
||||
env.ublog.automod.promptSetting
|
||||
env.report.automod.imageModelSetting,
|
||||
env.report.automod.imagePromptSetting,
|
||||
env.report.api.commsModelSetting,
|
||||
env.report.api.commsPromptSetting,
|
||||
env.ublog.ublogAutomod.modelSetting,
|
||||
env.ublog.ublogAutomod.promptSetting
|
||||
)
|
||||
|
||||
def settings = Secure(_.Settings) { _ ?=> _ ?=>
|
||||
|
||||
@@ -147,7 +147,9 @@ final class Main(
|
||||
lila.memo.PicfitApi.uploadForm.bindFromRequest().value,
|
||||
lila.ui.bits.imageDesignWidth(rel)
|
||||
)
|
||||
.map(url => JsonOk(Json.obj("imageUrl" -> url)))
|
||||
.map: url =>
|
||||
// env.report.api.automodImageRequest(url)
|
||||
JsonOk(Json.obj("imageUrl" -> url))
|
||||
.recover { case e: Exception => JsonBadRequest(e.getMessage) }
|
||||
}
|
||||
|
||||
|
||||
@@ -365,7 +365,7 @@ lazy val study = module("study",
|
||||
).dependsOn(common % "test->test")
|
||||
|
||||
lazy val relay = module("relay",
|
||||
Seq(study, game),
|
||||
Seq(study, game, report),
|
||||
Seq(chess.tiebreak) ++ tests.bundle
|
||||
)
|
||||
|
||||
@@ -430,7 +430,7 @@ lazy val msg = module("msg",
|
||||
)
|
||||
|
||||
lazy val forum = module("forum",
|
||||
Seq(memo, ui),
|
||||
Seq(memo, ui, report),
|
||||
Seq()
|
||||
)
|
||||
|
||||
@@ -440,7 +440,7 @@ lazy val forumSearch = module("forumSearch",
|
||||
)
|
||||
|
||||
lazy val team = module("team",
|
||||
Seq(memo, room, ui),
|
||||
Seq(memo, room, ui, report),
|
||||
Seq()
|
||||
)
|
||||
|
||||
|
||||
@@ -266,6 +266,8 @@ object mon:
|
||||
object automod:
|
||||
val request = future("mod.report.automod.request")
|
||||
def assessment(a: String) = counter("mod.report.automod.assessment").withTag("assessment", a)
|
||||
val imageRequest = future("mod.report.automod.image.request")
|
||||
val imageFlagged = counter("mod.report.automod.image.flagged").withoutTags()
|
||||
object log:
|
||||
val create = counter("mod.log.create").withoutTags()
|
||||
object irwin:
|
||||
|
||||
@@ -22,7 +22,13 @@ case class PicfitImage(
|
||||
name: String,
|
||||
size: Int, // in bytes
|
||||
createdAt: Instant,
|
||||
context: Option[String]
|
||||
context: Option[String],
|
||||
automod: Option[ImageAutomod] = none
|
||||
)
|
||||
|
||||
case class ImageAutomod(
|
||||
flagged: Option[String] = none,
|
||||
processed: Boolean = false
|
||||
)
|
||||
|
||||
case class ImageMetaData(
|
||||
@@ -94,6 +100,23 @@ final class PicfitApi(coll: Coll, val url: PicfitUrl, ws: StandaloneWSClient, co
|
||||
.flatMap { _.result[PicfitImage].so(picfitServer.delete) }
|
||||
.void
|
||||
|
||||
def setContext(context: String, ids: ImageId*): Funit =
|
||||
coll.update.one($inIds(ids), $set("context" -> context), multi = true).void
|
||||
|
||||
def setAutomod(id: ImageId, flagged: Option[String]): Funit =
|
||||
val op = flagged.fold($doc($unset("automod.flagged") ++ $set("automod.processed" -> true))): reason =>
|
||||
$set("automod.processed" -> true, "automod.flagged" -> reason)
|
||||
coll.update.one($id(id), op).void
|
||||
|
||||
def byIds(ids: ImageId*): Fu[Seq[PicfitImage]] =
|
||||
coll
|
||||
.find($inIds(ids))
|
||||
.cursor[PicfitImage]()
|
||||
.list(ids.size)
|
||||
|
||||
// def flaggedByUser(user: UserId): Fu[Seq[PicfitImage]] =
|
||||
// coll.find($doc("user" -> user, "automod.flagged" -> $exists(true))).cursor[PicfitImage]().collect()
|
||||
|
||||
object bodyImage:
|
||||
def upload(rel: String, image: FilePart, meta: Option[ImageMetaData], widthCap: Option[Int])(using
|
||||
me: Me
|
||||
@@ -147,6 +170,7 @@ object PicfitApi:
|
||||
private type ByteSource = Source[ByteString, ?]
|
||||
private type SourcePart = MultipartFormData.FilePart[ByteSource]
|
||||
|
||||
private given BSONDocumentHandler[ImageAutomod] = Macros.handler
|
||||
private given BSONDocumentHandler[PicfitImage] = Macros.handler
|
||||
|
||||
// from playframework/transport/client/play-ws/src/main/scala/play/api/libs/ws/WSBodyWritables.scala
|
||||
@@ -160,7 +184,7 @@ object PicfitApi:
|
||||
|
||||
def findInMarkdown(md: Markdown): Set[ImageId] =
|
||||
// path=ublogBody:mdTLUTfzboGg:wVo9Pqru.jpg
|
||||
val regex = """(?i)&path=([a-z]\w+:[a-z0-9]{12}:[a-z0-9]{8}\.\w{3,4})&""".r
|
||||
val regex = """(?i)[\?&]path=([a-z]\w+:[a-z0-9]{12}:[a-z0-9]{8}\.\w{3,4})&""".r
|
||||
regex
|
||||
.findAllMatchIn(md.value)
|
||||
.map(_.group(1))
|
||||
@@ -180,6 +204,10 @@ object PicfitApi:
|
||||
|
||||
final class PicfitUrl(config: PicfitConfig)(using Executor) extends lila.core.misc.PicfitUrl:
|
||||
|
||||
val origin =
|
||||
val pathBegin = config.endpointGet.indexOf('/', 8)
|
||||
if pathBegin == -1 then config.endpointGet else config.endpointGet.slice(0, pathBegin)
|
||||
|
||||
// This operation will able you to resize the image to the specified width and height.
|
||||
// Preserves the aspect ratio
|
||||
def resize(
|
||||
@@ -190,6 +218,8 @@ final class PicfitUrl(config: PicfitConfig)(using Executor) extends lila.core.mi
|
||||
height = ~size.toOption
|
||||
)
|
||||
|
||||
def contain(id: ImageId, size: Int) = display(id, "resize")(width = size, height = size)
|
||||
|
||||
// Thumbnail scales the image up or down using the specified resample filter,
|
||||
// crops it to the specified width and height and returns the transformed image.
|
||||
// Preserves the aspect ratio
|
||||
|
||||
@@ -5,32 +5,60 @@ import play.api.libs.json.*
|
||||
import play.api.libs.ws.StandaloneWSClient
|
||||
import play.api.libs.ws.JsonBodyWritables.*
|
||||
import play.api.libs.ws.JsonBodyReadables.*
|
||||
import scala.util.matching.Regex.quote
|
||||
|
||||
import lila.core.config.Secret
|
||||
import lila.core.data.Text
|
||||
import lila.common.autoconfig.AutoConfig
|
||||
import lila.common.config.given
|
||||
import lila.memo.SettingStore.Text.given
|
||||
import lila.core.id.ImageId
|
||||
|
||||
private final class Automod(ws: StandaloneWSClient, appConfig: Configuration)(using Executor):
|
||||
final class Automod(
|
||||
ws: StandaloneWSClient,
|
||||
appConfig: Configuration,
|
||||
settingStore: lila.memo.SettingStore.Builder,
|
||||
picfitApi: lila.memo.PicfitApi,
|
||||
picfitUrl: lila.memo.PicfitUrl
|
||||
)(using Executor):
|
||||
|
||||
private val config = appConfig.get[Automod.Config]("automod")
|
||||
|
||||
def request(
|
||||
private val imageIdRe =
|
||||
raw"""(?i)!\[(?:[^\n]*)\]\(${quote(
|
||||
picfitUrl.origin
|
||||
)}[^)\s]+[?&]path=([a-z]\w+:[a-z0-9]{12}:[a-z0-9]{8}\.\w{3,4})[^)]*\)""".r
|
||||
|
||||
val imagePromptSetting = settingStore[Text](
|
||||
"imageAutomodPrompt",
|
||||
text = "Image automod prompt".some,
|
||||
default = Text("")
|
||||
)
|
||||
|
||||
val imageModelSetting = settingStore[String](
|
||||
"imageAutomodModel",
|
||||
text = "Image automod model".some,
|
||||
default = "Qwen/Qwen2.5-VL-72B-Instruct"
|
||||
)
|
||||
|
||||
def text(
|
||||
userText: String,
|
||||
systemPrompt: Text,
|
||||
model: String,
|
||||
temperature: Double
|
||||
temperature: Double = 0,
|
||||
maxTokens: Int = 4096
|
||||
): Fu[Option[JsObject]] =
|
||||
(config.apiKey.value.nonEmpty && systemPrompt.value.nonEmpty && userText.nonEmpty).so:
|
||||
val body = Json.obj(
|
||||
"model" -> model,
|
||||
"temperature" -> temperature,
|
||||
"max_tokens" -> 4096,
|
||||
"messages" -> Json.arr(
|
||||
Json.obj("role" -> "system", "content" -> systemPrompt.value),
|
||||
Json.obj("role" -> "user", "content" -> userText)
|
||||
val body = Json
|
||||
.obj(
|
||||
"model" -> model,
|
||||
"temperature" -> temperature,
|
||||
"max_tokens" -> maxTokens,
|
||||
"messages" -> Json.arr(
|
||||
Json.obj("role" -> "system", "content" -> systemPrompt.value),
|
||||
Json.obj("role" -> "user", "content" -> userText)
|
||||
)
|
||||
)
|
||||
)
|
||||
ws.url(config.url)
|
||||
.withHttpHeaders(
|
||||
"Authorization" -> s"Bearer ${config.apiKey.value}",
|
||||
@@ -48,6 +76,61 @@ private final class Automod(ws: StandaloneWSClient, appConfig: Configuration)(us
|
||||
case None => fufail(s"${rsp.status} ${(rsp.body: String).take(500)}")
|
||||
case Some(res) => fuccess(res)
|
||||
|
||||
def markdownImages(markdown: String): Fu[Seq[lila.memo.PicfitImage]] =
|
||||
val idToUrl = imageIdRe
|
||||
.findAllMatchIn(markdown)
|
||||
.map: m =>
|
||||
val id = lila.core.id.ImageId(m.group(1))
|
||||
id -> picfitUrl.contain(id, 560)
|
||||
.toMap
|
||||
picfitApi
|
||||
.byIds(idToUrl.keys.toSeq*)
|
||||
.flatMap:
|
||||
_.map: pic =>
|
||||
pic.automod match
|
||||
case Some(a) if a.processed => fuccess(pic)
|
||||
case _ =>
|
||||
image(idToUrl.get(pic.id).get).flatMap: verdict =>
|
||||
picfitApi
|
||||
.setAutomod(pic.id, verdict)
|
||||
.inject:
|
||||
pic.copy(automod = lila.memo.ImageAutomod(verdict, true).some)
|
||||
.toSeq.parallel
|
||||
|
||||
def image(imageUrl: String): Fu[Option[String]] =
|
||||
import play.api.libs.json.Json
|
||||
(config.apiKey.value.nonEmpty && imagePromptSetting.get().value.nonEmpty).so:
|
||||
val body = Json
|
||||
.obj(
|
||||
"model" -> imageModelSetting.get(),
|
||||
"temperature" -> 0,
|
||||
"messages" -> Json.arr(
|
||||
Json.obj(
|
||||
"role" -> "user",
|
||||
"content" -> Json.arr(
|
||||
Json.obj("type" -> "text", "text" -> imagePromptSetting.get().value),
|
||||
Json.obj("type" -> "image_url", "image_url" -> Json.obj("url" -> imageUrl))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
ws.url(config.url)
|
||||
.withHttpHeaders(
|
||||
"Authorization" -> s"Bearer ${config.apiKey.value}",
|
||||
"Content-Type" -> "application/json"
|
||||
)
|
||||
.post(body)
|
||||
.map: rsp =>
|
||||
for
|
||||
choices <- (rsp.body \ "choices").asOpt[List[JsObject]]
|
||||
if rsp.status == 200
|
||||
best <- choices.headOption
|
||||
msg <- (best \ "message" \ "content").asOpt[String]
|
||||
trimmed = msg.slice(msg.indexOf('{', msg.indexOf("</think>")), msg.lastIndexOf('}') + 1)
|
||||
res <- Json.parse(trimmed).asOpt[JsObject]
|
||||
if ~(res \ "flag").asOpt[Boolean]
|
||||
yield ~(res \ "reason").asOpt[String]
|
||||
|
||||
private object Automod:
|
||||
case class Config(val url: String, val apiKey: Secret)
|
||||
given ConfigLoader[Config] = AutoConfig.loader[Config]
|
||||
|
||||
@@ -19,12 +19,16 @@ final class Env(
|
||||
settingStore: lila.memo.SettingStore.Builder,
|
||||
cacheApi: lila.memo.CacheApi,
|
||||
appConfig: play.api.Configuration,
|
||||
ws: play.api.libs.ws.StandaloneWSClient
|
||||
ws: play.api.libs.ws.StandaloneWSClient,
|
||||
net: lila.core.config.NetConfig,
|
||||
picfitUrl: lila.memo.PicfitUrl,
|
||||
picfitApi: lila.memo.PicfitApi
|
||||
)(using Executor, NetDomain)(using scheduler: Scheduler):
|
||||
|
||||
private def lazyPlaybansOf = () => playbansOf
|
||||
|
||||
private lazy val reportColl = db(CollName("report2"))
|
||||
private val assetDomain = net.assetDomain
|
||||
|
||||
lazy val scoreThresholdsSetting = ReportThresholds.makeScoreSetting(settingStore)
|
||||
|
||||
@@ -42,7 +46,7 @@ final class Env(
|
||||
private given UserIdOf[Report.SnoozeKey] = _.snoozerId
|
||||
private lazy val snoozer = lila.memo.Snoozer[Report.SnoozeKey]("report.snooze", cacheApi)
|
||||
|
||||
private val automod = Automod(ws, appConfig)
|
||||
lazy val automod = wire[Automod]
|
||||
lazy val api = wire[ReportApi]
|
||||
|
||||
lazy val modFilters = new ModReportFilter
|
||||
|
||||
@@ -25,7 +25,8 @@ final class ReportApi(
|
||||
snoozer: lila.memo.Snoozer[Report.SnoozeKey],
|
||||
thresholds: Thresholds,
|
||||
automodApi: Automod,
|
||||
settingStore: lila.memo.SettingStore.Builder
|
||||
settingStore: lila.memo.SettingStore.Builder,
|
||||
picfitApi: lila.memo.PicfitApi
|
||||
)(using Executor, Scheduler, lila.core.config.NetDomain)
|
||||
extends lila.core.report.ReportApi:
|
||||
|
||||
@@ -36,13 +37,13 @@ final class ReportApi(
|
||||
|
||||
private lazy val scorer = wire[ReportScore]
|
||||
|
||||
val promptSetting = settingStore[Text](
|
||||
val commsPromptSetting = settingStore[Text](
|
||||
"commsAutomodPrompt",
|
||||
text = "Comms automod prompt".some,
|
||||
default = Text("")
|
||||
)
|
||||
|
||||
val modelSetting = settingStore[String](
|
||||
val commsModelSetting = settingStore[String](
|
||||
"commsAutomodModel",
|
||||
text = "Comms automod model".some,
|
||||
default = "Qwen/Qwen3-235B-A22B-Instruct-2507-tput"
|
||||
@@ -313,33 +314,43 @@ final class ReportApi(
|
||||
for _ <- doProcessReport(selector, unsetInquiry = true)
|
||||
yield onReportClose()
|
||||
|
||||
def automodRequest(
|
||||
userText: String,
|
||||
systemPrompt: Text,
|
||||
model: String,
|
||||
temperature: Double = 0
|
||||
): Fu[Option[play.api.libs.json.JsObject]] =
|
||||
automodApi.request(userText, systemPrompt, model, temperature)
|
||||
|
||||
def automodComms(userText: String, url: String)(using me: Me): Funit =
|
||||
val assessImages =
|
||||
automodApi
|
||||
.markdownImages(userText)
|
||||
.flatMap: images =>
|
||||
picfitApi.setContext(url, images.map(_.id)*).inject(images)
|
||||
val assessText = automodApi
|
||||
.text(
|
||||
userText,
|
||||
systemPrompt = commsPromptSetting.get(),
|
||||
model = commsModelSetting.get()
|
||||
)
|
||||
.monSuccess(_.mod.report.automod.request)
|
||||
for
|
||||
rsp <- automodRequest(userText, systemPrompt = promptSetting.get(), model = modelSetting.get())
|
||||
.monSuccess(_.mod.report.automod.request)
|
||||
(images, textResponse) <- assessImages.zip(assessText)
|
||||
flaggedImages = images.flatMap(_.automod.flatMap(_.flagged))
|
||||
suspectOpt <- getSuspect(me)
|
||||
reporter <- automodReporter
|
||||
yield for
|
||||
res <- rsp
|
||||
res <- textResponse
|
||||
fromLlm <- (res \ "assessment").asOpt[String]
|
||||
_ = lila.mon.mod.report.automod.assessment(if fromLlm == "pass" then "ok" else fromLlm).increment()
|
||||
reason <- Reason(if fromLlm == "other" then "comm" else fromLlm) // to avoid explaining "comm" in prompt
|
||||
hasFlaggedImages = flaggedImages.size > 0
|
||||
kamonTag = if hasFlaggedImages then "image" else if fromLlm == "pass" then "ok" else fromLlm
|
||||
_ = lila.mon.mod.report.automod.assessment(kamonTag).increment()
|
||||
reason <- fromLlm match
|
||||
case "pass" if hasFlaggedImages => Reason("comm")
|
||||
case "other" => Reason("comm") // llm knows "other"
|
||||
case r => Reason(r)
|
||||
suspect <- suspectOpt
|
||||
summary = ~(res \ "reason").asOpt[String]
|
||||
summary = (flaggedImages ++ (res \ "reason").asOpt[String]).mkString(", ")
|
||||
yield create(
|
||||
Candidate(
|
||||
reporter = reporter,
|
||||
suspect = suspect,
|
||||
reason = reason,
|
||||
text = s"AUTOMOD: $summary $url"
|
||||
text = (hasFlaggedImages.option("IMAGE") ++ (fromLlm != "pass").option("TEXT")).mkString("/") +
|
||||
s" AI: $summary $url"
|
||||
)
|
||||
).recoverWith: e =>
|
||||
logger.warn(s"Comms automod failed for ${me.username}: ${e.getMessage}", e)
|
||||
|
||||
@@ -31,7 +31,8 @@ final class Env(
|
||||
settingStore: lila.memo.SettingStore.Builder,
|
||||
client: lila.search.client.SearchClient,
|
||||
reportApi: lila.report.ReportApi,
|
||||
lightUser: lila.core.LightUser.GetterSync
|
||||
lightUser: lila.core.LightUser.GetterSync,
|
||||
automod: lila.report.Automod
|
||||
)(using Executor, Scheduler, play.api.Mode):
|
||||
|
||||
export net.{ assetBaseUrl, baseUrl, domain, assetDomain }
|
||||
@@ -41,7 +42,7 @@ final class Env(
|
||||
|
||||
val topic = wire[UblogTopicApi]
|
||||
|
||||
val automod = wire[UblogAutomod]
|
||||
val ublogAutomod = wire[UblogAutomod]
|
||||
|
||||
val api: UblogApi = wire[UblogApi]
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ final class UblogApi(
|
||||
picfitApi: PicfitApi,
|
||||
shutupApi: ShutupApi,
|
||||
irc: lila.core.irc.IrcApi,
|
||||
automod: UblogAutomod,
|
||||
ublogAutomod: UblogAutomod,
|
||||
config: UblogConfig,
|
||||
settingStore: lila.memo.SettingStore.Builder,
|
||||
cacheApi: lila.memo.CacheApi
|
||||
@@ -194,7 +194,7 @@ final class UblogApi(
|
||||
def triggerAutomod(post: UblogPost): Fu[Option[UblogAutomod.Assessment]] =
|
||||
val retries = 5 // 30s, 1m, 2m, 4m, 8m
|
||||
def attempt(n: Int): Fu[Option[UblogAutomod.Assessment]] =
|
||||
automod(post, n * 0.1)
|
||||
ublogAutomod(post, n * 0.1)
|
||||
.flatMapz: llm =>
|
||||
val result = post.automod.foldLeft(llm)(_.updateByLLM(_))
|
||||
for _ <- colls.post.updateField($id(post.id), "automod", result)
|
||||
|
||||
@@ -46,8 +46,10 @@ object UblogAutomod:
|
||||
private given Reads[FuzzyResult] = Json.reads[FuzzyResult]
|
||||
|
||||
private final class UblogAutomod(
|
||||
reportApi: lila.report.ReportApi,
|
||||
settingStore: lila.memo.SettingStore.Builder
|
||||
automod: lila.report.Automod,
|
||||
settingStore: lila.memo.SettingStore.Builder,
|
||||
picfitApi: lila.memo.PicfitApi,
|
||||
picfitUrl: lila.memo.PicfitUrl
|
||||
)(using Executor):
|
||||
|
||||
import UblogAutomod.*
|
||||
@@ -65,22 +67,34 @@ private final class UblogAutomod(
|
||||
)
|
||||
|
||||
private[ublog] def apply(post: UblogPost, temperature: Double = 0): Fu[Option[Assessment]] = post.live.so:
|
||||
val userText = post.allText.take(40_000) // bin/ublog-automod.mjs, important for hash
|
||||
reportApi
|
||||
.automodRequest(
|
||||
userText = userText,
|
||||
systemPrompt = promptSetting.get(),
|
||||
model = modelSetting.get(),
|
||||
temperature = temperature
|
||||
)
|
||||
.map:
|
||||
_.flatMap(normalize).so: res =>
|
||||
lila.mon.ublog.automod.quality(res.quality.toString).increment()
|
||||
lila.mon.ublog.automod.flagged(res.flagged.isDefined).increment()
|
||||
res.copy(hash = Algo.sha256(userText).hex.take(12).some).some // matches ublog-automod.mjs hash
|
||||
.monSuccess(_.ublog.automod.request)
|
||||
val assessImages =
|
||||
automod
|
||||
.markdownImages(~post.image.map(i => s"})\n") + post.allText)
|
||||
.flatMap: images =>
|
||||
picfitApi.setContext(s"/ublog/${post.id}/redirect", images.map(_.id)*).inject(images)
|
||||
val assessText =
|
||||
val userText = post.allText.take(40_000) // match bin/ublog-automod.mjs hash
|
||||
automod
|
||||
.text(
|
||||
userText = userText,
|
||||
systemPrompt = promptSetting.get(),
|
||||
model = modelSetting.get(),
|
||||
temperature = temperature
|
||||
)
|
||||
.map:
|
||||
_.flatMap(normalize).so: res =>
|
||||
lila.mon.ublog.automod.quality(res.quality.toString).increment()
|
||||
lila.mon.ublog.automod.flagged(res.flagged.isDefined).increment()
|
||||
res.copy(hash = Algo.sha256(userText).hex.take(12).some).some // match bin/ublog-automod.mjs hash
|
||||
.monSuccess(_.ublog.automod.request)
|
||||
assessImages
|
||||
.zip(assessText)
|
||||
.map: (imgs, text) =>
|
||||
val flags = imgs.flatMap(_.automod.flatMap(_.flagged))
|
||||
if flags.nonEmpty then text.map(t => t.copy(flagged = (t.flagged ++ flags).mkString(", ").some))
|
||||
else text
|
||||
|
||||
private def normalize(rsp: JsObject): Option[Assessment] = // keep in sync with bin/ublog-automod.mjs
|
||||
private def normalize(rsp: JsObject): Option[Assessment] =
|
||||
rsp
|
||||
.asOpt[FuzzyResult]
|
||||
.flatMap: res =>
|
||||
|
||||
@@ -13,6 +13,9 @@ import ScalatagsTemplate.{ *, given }
|
||||
|
||||
object bits:
|
||||
|
||||
private var picfitOrigin: Option[String] = none // nothing says i love you like a fresh var
|
||||
def setPicfitOrigin(origin: String) = picfitOrigin = origin.some // injected from the app
|
||||
|
||||
val engineFullName = "Stockfish 17.1"
|
||||
|
||||
def subnav(mods: Modifier*) = st.aside(cls := "subnav"):
|
||||
@@ -163,8 +166,9 @@ object bits:
|
||||
def markdownTextarea(picfitIdPrefix: Option[String])(modifiers: Modifier*) =
|
||||
div(
|
||||
cls := "markdown-textarea",
|
||||
picfitIdPrefix.map(id => attr("data-image-upload-url") := routes.Main.uploadImage(id)),
|
||||
picfitIdPrefix.flatMap(id => imageDesignWidth(id)).map(dw => attr("data-image-design-width") := dw),
|
||||
picfitIdPrefix.map(pre => attr("data-image-upload-url") := routes.Main.uploadImage(pre)),
|
||||
picfitOrigin.map(origin => attr("data-image-download-origin") := origin),
|
||||
picfitIdPrefix.flatMap(pre => imageDesignWidth(pre)).map(w => attr("data-image-design-width") := w),
|
||||
attr("data-image-resize-url") := "/image-url"
|
||||
)(
|
||||
div(cls := "comment-header")(
|
||||
|
||||
@@ -108,9 +108,7 @@ export const env = new (class {
|
||||
if (this.manifestOk() && taskOk()) {
|
||||
if (this.startTime) {
|
||||
const doneMsg = `Done in ${c.green((Date.now() - this.startTime) / 1000 + '')}s`;
|
||||
this.log(
|
||||
doneMsg + (this.stdin ? `. Press ${c.grey('<space>')} to clean, ${c.grey('<esc>')} to exit` : ''),
|
||||
);
|
||||
this.log(doneMsg + (this.stdin ? `. Press ${c.grey('<space>')} to clean and rebuild` : ''));
|
||||
}
|
||||
updateManifest();
|
||||
this.startTime = undefined;
|
||||
|
||||
@@ -20,7 +20,7 @@ type MenuItem = {
|
||||
|
||||
site.load.then(() => {
|
||||
const containers = Array.from(document.querySelectorAll<HTMLElement>('.dropdown-overflow'));
|
||||
if (containers.length === 0) {
|
||||
if (site.blindMode || containers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ function wireMarkdownTextarea(markdown: HTMLElement) {
|
||||
markdown: (text?: string) => (text !== undefined ? (textarea.value = text) : textarea.value),
|
||||
},
|
||||
designWidth: Number(markdown.dataset.imageDesignWidth),
|
||||
origin: markdown.dataset.imageDownloadOrigin,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@ import { currentTheme } from 'lib/device';
|
||||
import { wireMarkdownImgResizers, wrapImg, naturalSize } from 'lib/view/markdownImgResizer';
|
||||
|
||||
export function makeToastEditor(el: HTMLTextAreaElement, text: string = '', height: string = '60vh'): Editor {
|
||||
const designWidth = Number(el.dataset.imageDesignWidth);
|
||||
const rewire = () =>
|
||||
wireMarkdownImgResizers({
|
||||
root: document.querySelector<HTMLElement>('.toastui-editor-ww-container .ProseMirror')!,
|
||||
update: { url: updateImage },
|
||||
designWidth,
|
||||
designWidth: Number(el.dataset.imageDesignWidth),
|
||||
origin: el.dataset.imageDownloadOrigin,
|
||||
});
|
||||
const editor = newToast(el, text, rewire, height);
|
||||
rewire();
|
||||
|
||||
@@ -294,7 +294,6 @@ export class EditDialog {
|
||||
],
|
||||
});
|
||||
if (dlg.returnValue !== 'save') return;
|
||||
|
||||
this.editing().vision = view.querySelector<HTMLTextAreaElement>('.vision')!.value;
|
||||
this.makeEditView();
|
||||
this.update();
|
||||
|
||||
@@ -241,7 +241,7 @@ export const schema: Schema = deepFreeze<Schema>({
|
||||
value: { range: { min: 0, max: 100 }, by: 'max' },
|
||||
requires: 'bot_filters_cplTarget',
|
||||
title: $trim`
|
||||
cpl stdev, if given, describes the standard deviation of the folder normal
|
||||
cpl stdev, if given, describes the standard deviation of the folded normal
|
||||
distribution from which each move's random cpl target is chosen. if not given, it defaults
|
||||
to 50. cpl stdev participates in the cpl target
|
||||
weight calculation. it does not assign its own weight.`,
|
||||
@@ -313,7 +313,7 @@ export const schema: Schema = deepFreeze<Schema>({
|
||||
|
||||
move quality decay is engine independent and can be used to resolve between
|
||||
scored stockfish and unscored lc0 moves.
|
||||
it operates on the full list provided by engine behavior and pairs well
|
||||
it operates on the full list provided by both engines and pairs well
|
||||
with the think time facet and a crisp chardonnay.`,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,18 +10,29 @@ export type UpdateImageHook =
|
||||
| { markdown: Prop<string> }
|
||||
| { url: (img: HTMLElement, newUrl: string, width: number) => void };
|
||||
|
||||
type ResizerOptions = {
|
||||
export type ResizeArgs = {
|
||||
root: HTMLElement;
|
||||
update: UpdateImageHook;
|
||||
designWidth?: number;
|
||||
origin?: string;
|
||||
};
|
||||
|
||||
export async function wireMarkdownImgResizers({ root, update, designWidth }: ResizerOptions): Promise<void> {
|
||||
export async function wireMarkdownImgResizers({
|
||||
root,
|
||||
update,
|
||||
designWidth,
|
||||
origin,
|
||||
}: ResizeArgs): Promise<void> {
|
||||
let rootStyle: CSSStyleDeclaration;
|
||||
let rootPadding: number;
|
||||
globalImageLinkRe ??= new RegExp(
|
||||
String.raw`!\[([^\n]*)\]\((${regexQuote(origin ?? 'http')}[^)\s]+[?&]path=([a-z]\w+:[a-z0-9]{12}:[a-z0-9]{8}\.\w{3,4})[^)]*)\)`,
|
||||
'gi',
|
||||
);
|
||||
|
||||
for (const [index, img] of root.querySelectorAll<HTMLImageElement>('img').entries()) {
|
||||
for (const img of root.querySelectorAll<HTMLImageElement>('img')) {
|
||||
if (img.closest('.markdown-img-resizer')) continue; // already wrapped
|
||||
if (origin && !img.src.startsWith(origin)) continue;
|
||||
await img.decode().catch(() => {});
|
||||
rootStyle ??= window.getComputedStyle(root);
|
||||
rootPadding ??= parseInt(rootStyle.paddingLeft) + parseInt(rootStyle.paddingRight);
|
||||
@@ -32,8 +43,6 @@ export async function wireMarkdownImgResizers({ root, update, designWidth }: Res
|
||||
const rootWidth = root.clientWidth - rootPadding;
|
||||
const imgClientRect = img.getBoundingClientRect();
|
||||
const aspectRatio = img.naturalHeight ? img.naturalWidth / img.naturalHeight : 1;
|
||||
// here, extrinsic is pixels in the current viewport (whereas intrinsic is natural/picfit size)
|
||||
const oldExtrinsicImgWidth = imgClientRect.width;
|
||||
const isBottomDrag = handle.className.includes('bottom');
|
||||
const isCornerDrag = !isBottomDrag && imgClientRect.bottom - down.clientY < 18;
|
||||
const dir = handle.className.includes('left') ? -1 : 1;
|
||||
@@ -41,7 +50,7 @@ export async function wireMarkdownImgResizers({ root, update, designWidth }: Res
|
||||
|
||||
handle.setPointerCapture?.(down.pointerId);
|
||||
img.style.willChange = 'width,height';
|
||||
img.style.width = `${oldExtrinsicImgWidth}px`;
|
||||
img.style.width = `${imgClientRect.width}px`;
|
||||
img.closest<HTMLElement>('.markdown-img-resizer')!.style.width = '';
|
||||
|
||||
const pointermove = (move: PointerEvent) => {
|
||||
@@ -50,14 +59,14 @@ export async function wireMarkdownImgResizers({ root, update, designWidth }: Res
|
||||
: isBottomDrag
|
||||
? (move.clientY - down.clientY) * aspectRatio
|
||||
: dir * 2 * (move.clientX - down.clientX);
|
||||
const newExtrinsicImgWidth = Math.round(
|
||||
clamp(oldExtrinsicImgWidth + deltaX, { min: 128, max: rootWidth }),
|
||||
const viewportImgWidth = Math.round(
|
||||
clamp(imgClientRect.width + deltaX, { min: 128, max: rootWidth }),
|
||||
);
|
||||
img.style.width = `${newExtrinsicImgWidth}px`;
|
||||
img.style.width = `${viewportImgWidth}px`;
|
||||
img.dataset.resizeWidth = String(
|
||||
designWidth ? Math.round((newExtrinsicImgWidth * designWidth) / rootWidth) : newExtrinsicImgWidth,
|
||||
designWidth ? Math.round((viewportImgWidth * designWidth) / rootWidth) : viewportImgWidth,
|
||||
);
|
||||
img.dataset.widthRatio = String(newExtrinsicImgWidth / rootWidth);
|
||||
img.dataset.widthRatio = String(viewportImgWidth / rootWidth);
|
||||
};
|
||||
const pointerup = async () => {
|
||||
handle.removeEventListener('pointermove', pointermove);
|
||||
@@ -76,12 +85,13 @@ export async function wireMarkdownImgResizers({ root, update, designWidth }: Res
|
||||
return;
|
||||
}
|
||||
const text = update.markdown();
|
||||
const path = [...text.matchAll(globalImageLinkRe)][index];
|
||||
if (!img.dataset.widthRatio || !path[1]) return;
|
||||
const { imageUrl } = await xhrJson(`/image-url/${path[2]}?width=${img.dataset.resizeWidth}`);
|
||||
const before = text.slice(0, path.index);
|
||||
const after = text.slice(path.index! + path[0].length);
|
||||
update.markdown(before + `![${path[1]}](${imageUrl})` + after);
|
||||
console.log(img.src, [...text.matchAll(globalImageLinkRe)]);
|
||||
const link = [...text.matchAll(globalImageLinkRe)].find(l => l[2] === img.src);
|
||||
if (!link?.[1] || !img.dataset.widthRatio) return;
|
||||
const { imageUrl } = await xhrJson(`/image-url/${link[3]}?width=${img.dataset.resizeWidth}`);
|
||||
const before = text.slice(0, link.index);
|
||||
const after = text.slice(link.index! + link[0].length);
|
||||
update.markdown(before + `![${link[1]}](${imageUrl})` + after);
|
||||
};
|
||||
handle.addEventListener('pointermove', pointermove, { passive: true });
|
||||
handle.addEventListener('pointerup', pointerup, { passive: true });
|
||||
@@ -122,12 +132,15 @@ export async function naturalSize(image: Blob): Promise<{ width: number; height:
|
||||
}
|
||||
}
|
||||
|
||||
let globalImageLinkRe: RegExp;
|
||||
const imageIdRe = /&path=([a-z]\w+:[a-z0-9]{12}:[a-z0-9]{8}\.\w{3,4})&/i;
|
||||
const globalImageLinkRe =
|
||||
/!\[([^\n]*)\]\(https:[^)\s]+&path=([a-z]\w+:[a-z0-9]{12}:[a-z0-9]{8}\.\w{3,4})&[^)]+\)/gi;
|
||||
|
||||
function dragHandles(img: HTMLImageElement): HTMLElement[] {
|
||||
const span = img.closest('.markdown-img-container') ?? wrapImg({ img });
|
||||
span.firstElementChild!.classList.add('markdown-img-resizer');
|
||||
return [...span.querySelectorAll<HTMLElement>('.resize-handle')];
|
||||
}
|
||||
|
||||
function regexQuote(origin: string) {
|
||||
return origin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user