Add OpenAI integration
authorLanius Trolling <lanius@laniustrolling.dev>
Sun, 21 Apr 2024 19:54:20 +0000 (15:54 -0400)
committerLanius Trolling <lanius@laniustrolling.dev>
Sun, 21 Apr 2024 19:54:20 +0000 (15:54 -0400)
33 files changed:
build.gradle.kts
src/jvmMain/kotlin/info/mechyrdia/Configuration.kt
src/jvmMain/kotlin/info/mechyrdia/Factbooks.kt
src/jvmMain/kotlin/info/mechyrdia/data/Bson.kt
src/jvmMain/kotlin/info/mechyrdia/data/Data.kt
src/jvmMain/kotlin/info/mechyrdia/data/Nations.kt
src/jvmMain/kotlin/info/mechyrdia/lore/April1st.kt
src/jvmMain/kotlin/info/mechyrdia/lore/AssetCaching.kt
src/jvmMain/kotlin/info/mechyrdia/lore/ParserHtml.kt
src/jvmMain/kotlin/info/mechyrdia/lore/ParserJson.kt [deleted file]
src/jvmMain/kotlin/info/mechyrdia/lore/ParserPreprocess.kt
src/jvmMain/kotlin/info/mechyrdia/lore/ParserPreprocessInclude.kt
src/jvmMain/kotlin/info/mechyrdia/lore/ParserPreprocessJson.kt
src/jvmMain/kotlin/info/mechyrdia/lore/ParserRobot.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/lore/ParserTree.kt
src/jvmMain/kotlin/info/mechyrdia/lore/ViewNav.kt
src/jvmMain/kotlin/info/mechyrdia/lore/ViewTpl.kt
src/jvmMain/kotlin/info/mechyrdia/lore/ViewsError.kt
src/jvmMain/kotlin/info/mechyrdia/lore/ViewsRss.kt
src/jvmMain/kotlin/info/mechyrdia/robot/RobotApi.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/robot/RobotCodec.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/robot/RobotFiles.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/robot/RobotRateLimiter.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/robot/RobotSchema.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/robot/RobotService.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/robot/RobotSse.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/robot/RobotUserLimiter.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/robot/ViewsRobot.kt [new file with mode: 0644]
src/jvmMain/kotlin/info/mechyrdia/route/ResourceCsrf.kt
src/jvmMain/kotlin/info/mechyrdia/route/ResourceHandler.kt
src/jvmMain/kotlin/info/mechyrdia/route/ResourceTypes.kt
src/jvmMain/resources/static/init.js
src/jvmMain/resources/static/style.css

index 38e8dae4d47e7d0bfeaef780130b2dff5d6c8385..e56ea3aaa6f2c8d1e102635fe3f6d82c90ef3f3d 100644 (file)
@@ -128,6 +128,11 @@ kotlin {
                
                val jvmMain by getting {
                        dependencies {
+                               implementation(kotlin("stdlib"))
+                               implementation(kotlin("stdlib-jdk7"))
+                               implementation(kotlin("stdlib-jdk8"))
+                               implementation(kotlin("reflect"))
+                               
                                implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0")
                                implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.8.0")
                                implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactive:1.8.0")
@@ -149,6 +154,7 @@ kotlin {
                                implementation("io.ktor:ktor-server-resources:2.3.10")
                                implementation("io.ktor:ktor-server-sessions-jvm:2.3.10")
                                implementation("io.ktor:ktor-server-status-pages:2.3.10")
+                               implementation("io.ktor:ktor-server-websockets:2.3.10")
                                
                                implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.10")
                                
@@ -165,6 +171,14 @@ kotlin {
                                implementation("org.slf4j:slf4j-api:2.0.7")
                                implementation("ch.qos.logback:logback-classic:1.4.14")
                                
+                               implementation("com.aallam.ktoken:ktoken:0.3.0")
+                               
+                               implementation("io.ktor:ktor-client-core:2.3.10")
+                               implementation("io.ktor:ktor-client-java:2.3.10")
+                               implementation("io.ktor:ktor-client-auth:2.3.10")
+                               implementation("io.ktor:ktor-client-content-negotiation:2.3.10")
+                               implementation("io.ktor:ktor-client-logging:2.3.10")
+                               
                                implementation(project(":fontparser"))
                                //implementation(project(":fightgame"))
                        }
index eba5a89129b2d88947820ed1f826ed69464b7b0d..e50b019cdd445b402b27df299573dcad0a46adf9 100644 (file)
@@ -17,6 +17,13 @@ sealed class FileStorageConfig {
        data object GridFs : FileStorageConfig()
 }
 
+@Serializable
+data class OpenAiConfig(
+       val token: String,
+       val orgId: String,
+       val project: String? = null,
+)
+
 @Serializable
 data class Configuration(
        val host: String = "127.0.0.1",
@@ -30,6 +37,8 @@ data class Configuration(
        val dbConn: String = "mongodb://localhost:27017",
        
        val ownerNation: String = "mechyrdia",
+       
+       val openAi: OpenAiConfig? = null,
 ) {
        companion object {
                val Current: Configuration by lazy {
index 253d464bd905758b4b98ec8911c3671cc8aba669..8823bb0a089558d36c1190682fdc1c2394ccf64e 100644 (file)
@@ -5,6 +5,8 @@ package info.mechyrdia
 import info.mechyrdia.auth.*
 import info.mechyrdia.data.*
 import info.mechyrdia.lore.*
+import info.mechyrdia.robot.JsonRobotCodec
+import info.mechyrdia.robot.RobotService
 import info.mechyrdia.route.*
 import io.ktor.http.*
 import io.ktor.http.content.*
@@ -30,6 +32,7 @@ import io.ktor.server.response.*
 import io.ktor.server.routing.*
 import io.ktor.server.sessions.*
 import io.ktor.server.sessions.serialization.*
+import io.ktor.server.websocket.*
 import org.slf4j.event.Level
 import java.io.IOException
 import java.util.concurrent.atomic.AtomicLong
@@ -43,6 +46,8 @@ fun main() {
        
        FileStorage.initialize()
        
+       RobotService.initialize()
+       
        embeddedServer(CIO, port = Configuration.Current.port, host = Configuration.Current.host, module = Application::factbooks).start(wait = true)
 }
 
@@ -183,6 +188,13 @@ fun Application.factbooks() {
                }
        }
        
+       install(WebSockets) {
+               pingPeriodMillis = 500L
+               timeoutMillis = 5000L
+               
+               contentConverter = KotlinxWebsocketSerializationConverter(JsonRobotCodec)
+       }
+       
        routing {
                staticResources("/static", "static", index = null) {
                        preCompressed(CompressedFileType.BROTLI, CompressedFileType.GZIP)
@@ -201,6 +213,8 @@ fun Application.factbooks() {
                get<Root.Auth.LoginPage>()
                post<Root.Auth.LoginPost, _>()
                post<Root.Auth.LogoutPost, _>()
+               get<Root.Nuke>()
+               ws<Root.Nuke.WS>()
                get<Root.Comments.HelpPage>()
                get<Root.Comments.RecentPage>()
                post<Root.Comments.NewPost, _>()
index 584eb3ccb2403a1cf95c0737b2fa238f4e2d533d..f56d9d5214099f07abe1b47347b2314c21fd601a 100644 (file)
@@ -23,6 +23,7 @@ import org.bson.codecs.kotlinx.BsonDecoder
 import org.bson.codecs.kotlinx.BsonEncoder
 import org.bson.types.ObjectId
 import java.time.Instant
+import kotlin.math.absoluteValue
 
 object IdCodec : Codec<Id<*>> {
        override fun getEncoderClass(): Class<Id<*>> {
@@ -65,21 +66,58 @@ object ObjectIdSerializer : KSerializer<ObjectId> {
        }
 }
 
+fun Instant.toSecondString(): String {
+       val (isNegative, wholeS, fracS) = if (epochSecond < 0 && nano > 0) {
+               Triple(true, epochSecond.absoluteValue - 1, 1_000_000_000 - nano)
+       } else Triple(epochSecond < 0, epochSecond.absoluteValue, nano)
+       
+       val sign = if (isNegative) "-" else ""
+       
+       val whole = wholeS.toString()
+       val frac = fracS.toString().padStart(9, '0').trimEnd('0')
+       
+       return if (frac.isEmpty())
+               "$sign$whole"
+       else
+               "$sign$whole.$frac"
+}
+
+private val instantSecondRegex = Regex("([+-]?)([0-9]+)(?:\\.([0-9]{1,9}))?")
+
+fun String.toSecondInstant() = toSecondInstantOrNull() ?: throw IllegalArgumentException("String given to toSecondInstant must match regex /${instantSecondRegex.pattern}/, got $this")
+
+fun String.toSecondInstantOrNull(): Instant? {
+       val matchResult = instantSecondRegex.matchEntire(this) ?: return null
+       val (signStr, wholeStr, fracStr) = matchResult.destructured
+       val isNegative = signStr == "-"
+       
+       val wholeS = wholeStr.toLong()
+       val fracS = if (fracStr.isEmpty()) 0 else fracStr.toInt()
+       
+       val (seconds, nanos) = if (isNegative) {
+               if (fracS > 0)
+                       (-wholeS - 1) to (1_000_000_000 - fracS)
+               else -wholeS to 0
+       } else wholeS to fracS
+       
+       return Instant.ofEpochSecond(seconds, nanos.toLong())
+}
+
 object InstantSerializer : KSerializer<Instant> {
-       override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("InstantSerializer", PrimitiveKind.LONG)
+       override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("InstantSerializer", PrimitiveKind.STRING)
        
        override fun serialize(encoder: Encoder, value: Instant) {
-               if (encoder !is BsonEncoder)
-                       throw SerializationException("Instant is not supported by ${encoder::class}")
-               
-               encoder.encodeBsonValue(BsonDateTime(value.toEpochMilli()))
+               if (encoder is BsonEncoder)
+                       encoder.encodeBsonValue(BsonDateTime(value.toEpochMilli()))
+               else
+                       encoder.encodeString(value.toSecondString())
        }
        
        override fun deserialize(decoder: Decoder): Instant {
-               if (decoder !is BsonDecoder)
-                       throw SerializationException("Instant is not supported by ${decoder::class}")
-               
-               return Instant.ofEpochMilli(decoder.decodeBsonValue().asDateTime().value)
+               return if (decoder is BsonDecoder)
+                       Instant.ofEpochMilli(decoder.decodeBsonValue().asDateTime().value)
+               else
+                       decoder.decodeString().toSecondInstant()
        }
 }
 
index 3281845d2dcbb2a4663d93d2bc40114c08bb6a7f..66afe39f2442d7c3d4bc8c085ca003f1ca14227c 100644 (file)
@@ -12,6 +12,7 @@ import com.mongodb.reactivestreams.client.gridfs.GridFSBucket
 import com.mongodb.reactivestreams.client.gridfs.GridFSBuckets
 import info.mechyrdia.auth.SessionStorageDoc
 import info.mechyrdia.auth.WebDavToken
+import info.mechyrdia.robot.RobotGlobals
 import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.singleOrNull
@@ -239,7 +240,8 @@ interface TableHolder<T : DataDocument<T>> {
                        WebDavToken,
                        Comment,
                        CommentReplyLink,
-                       PageVisitData
+                       PageVisitData,
+                       RobotGlobals,
                )
        }
 }
index 1cac61c94f7359184cbc607e9c8c51027e9c5262..ff42a35c9087fb6d5f4df86e75c8bf2ca2efd2ae 100644 (file)
@@ -52,10 +52,11 @@ data class NationData(
 val CallNationCacheAttribute = AttributeKey<MutableMap<Id<NationData>, NationData>>("Mechyrdia.NationCache")
 
 val ApplicationCall.nationCache: MutableMap<Id<NationData>, NationData>
-       get() = attributes.getOrNull(CallNationCacheAttribute)
-               ?: ConcurrentHashMap<Id<NationData>, NationData>().also { cache ->
+       get() = attributes.computeIfAbsent(CallNationCacheAttribute) {
+               ConcurrentHashMap<Id<NationData>, NationData>().also { cache ->
                        attributes.put(CallNationCacheAttribute, cache)
                }
+       }
 
 suspend fun MutableMap<Id<NationData>, NationData>.getNation(id: Id<NationData>): NationData {
        return getOrPut(id) {
@@ -72,23 +73,27 @@ fun ApplicationCall.ownerNationOnly() {
 
 suspend fun ApplicationCall.currentNation(): NationData? {
        attributes.getOrNull(CallCurrentNationAttribute)?.let { sess ->
-               return when (sess) {
-                       NationSession.Anonymous -> null
-                       is NationSession.LoggedIn -> sess.nation
-               }
+               return sess.nation
        }
        
-       val nationId = sessions.get<UserSession>()?.nationId
-       return if (nationId == null) {
-               attributes.put(CallCurrentNationAttribute, NationSession.Anonymous)
-               null
-       } else nationCache.getNation(nationId).also { data ->
-               attributes.put(CallCurrentNationAttribute, NationSession.LoggedIn(data))
-       }
+       return sessions.get<UserSession>()
+               ?.nationId
+               ?.let { nationCache.getNation(it) }
+               ?.also { attributes.put(CallCurrentNationAttribute, NationSession(it)) }
 }
 
+private fun NationSession(nation: NationData?) = if (nation == null)
+       NationSession.Anonymous
+else
+       NationSession.LoggedIn(nation)
+
 private sealed class NationSession {
-       data object Anonymous : NationSession()
+       abstract val nation: NationData?
+       
+       data object Anonymous : NationSession() {
+               override val nation: NationData?
+                       get() = null
+       }
        
-       data class LoggedIn(val nation: NationData) : NationSession()
+       data class LoggedIn(override val nation: NationData) : NationSession()
 }
index d2db93495d7119f08128533e688bf38591ac81dc..e7a064152f0b49aa6b74085519358ba1332e8e7b 100644 (file)
@@ -7,10 +7,10 @@ import java.time.Instant
 import java.time.Month
 import java.time.ZoneId
 
-private val myTimeZone: ZoneId = ZoneId.of("America/New_York")
+val MyTimeZone: ZoneId = ZoneId.of("America/New_York")
 
 fun isApril1st(time: Instant = Instant.now()): Boolean {
-       val zonedDateTime = time.atZone(myTimeZone)
+       val zonedDateTime = time.atZone(MyTimeZone)
        return zonedDateTime.month == Month.APRIL && zonedDateTime.dayOfMonth == 1
 }
 
index 4db2e64bf4820c71aaf3bf15e022b8276c5ec446..4c9128f180129fc495d541dd060aa16e9ddbb631 100644 (file)
@@ -40,7 +40,7 @@ abstract class FileDependentCache<T : Any> {
        private val cache = ConcurrentHashMap<StoragePath, Entry>()
        
        private suspend fun Entry(path: StoragePath) = cacheLock.withLock {
-               cache.computeIfAbsent(path) {
+               cache.getOrPut(path) {
                        Entry(null, null)
                }
        }
index c6fbe098885656579d8ff41b4d65b7ec6ac6f52e..17e67f40b36fe307c8e8bf80c2cc2d3e0dfcda3a 100644 (file)
@@ -6,6 +6,7 @@ import kotlinx.html.*
 import kotlinx.html.org.w3c.dom.events.Event
 import kotlinx.html.stream.createHTML
 import kotlinx.serialization.json.JsonPrimitive
+import java.time.Instant
 import kotlin.text.toCharArray
 
 typealias HtmlBuilderContext = Unit
@@ -131,9 +132,11 @@ object HtmlLexerProcessor : LexerTagFallback<HtmlBuilderContext, HtmlBuilderSubj
        )
        
        override fun processInvalidTag(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, tag: String, param: String?, subNodes: ParserTree): HtmlBuilderSubject {
+               val content = env.processTree(subNodes)
+               
                return {
                        +if (param == null) "[$tag]" else "[$tag=$param]"
-                       env.processTree(subNodes)()
+                       content()
                        +"[/$tag]"
                }
        }
@@ -281,19 +284,19 @@ class HtmlTagLexerTag(
 val NON_ANCHOR_CHAR = Regex("[^a-zA-Z\\d\\-]+")
 fun String.sanitizeAnchor() = replace(NON_ANCHOR_CHAR, "-")
 
-fun ParserTree.treeToAnchorText(): String = treeToText().sanitizeAnchor()
-
-class HtmlHeaderLexerTag(val tagCreator: TagCreator, val anchor: (ParserTree) -> String?) : HtmlLexerTag {
+class HtmlHeaderLexerTag(val tagCreator: TagCreator, val anchor: (String) -> String?) : HtmlLexerTag {
        override fun processTag(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, param: String?, subNodes: ParserTree): HtmlBuilderSubject {
+               val content = subNodes.treeToText()
+               val anchorId = anchor(content)
+               val anchorHash = anchorId?.let { "#$it" }.orEmpty()
+               
                return {
-                       val anchorId = anchor(subNodes)
-                       
                        anchorId?.let { a { id = it } }
+                       
                        tagCreator {
-                               +subNodes.treeToText()
+                               +content
                        }
                        
-                       val anchorHash = anchorId?.let { "#$it" }.orEmpty()
                        script { unsafe { +"window.checkRedirectTarget(\"$anchorHash\");" } }
                }
        }
@@ -318,7 +321,15 @@ fun processColor(param: String?): Map<String, String> = param
        ?.let { mapOf("style" to "color:#$it") }
        .orEmpty()
 
-private val VALID_ALIGNMENTS = mapOf(
+fun <V> uncasedMapOf(vararg pairs: Pair<String, V>): Map<String, V> = buildMap {
+       pairs.associateTo(this) { (k, v) ->
+               k.lowercase() to v
+       }
+}
+
+fun <V> Map<String, V>.getUncased(key: String): V? = get(key.lowercase())
+
+private val VALID_ALIGNMENTS = uncasedMapOf(
        "left" to "text-align:left",
        "right" to "text-align:right",
        "center" to "text-align:center",
@@ -327,18 +338,18 @@ private val VALID_ALIGNMENTS = mapOf(
 
 fun processAlign(param: String?): Map<String, String> = param
        ?.lowercase()
-       ?.let { VALID_ALIGNMENTS[it] }
+       ?.let { VALID_ALIGNMENTS.getUncased(it) }
        ?.let { mapOf("style" to it) }
        .orEmpty()
 
-private val VALID_FLOATS = mapOf(
+private val VALID_FLOATS = uncasedMapOf(
        "left" to "float:left;max-width:var(--aside-width)",
        "right" to "float:right;max-width:var(--aside-width)",
 )
 
 fun processFloat(param: String?): Map<String, String> = param
        ?.lowercase()
-       ?.let { VALID_FLOATS[it] }
+       ?.let { VALID_FLOATS.getUncased(it) }
        ?.let { mapOf("style" to it) }
        .orEmpty()
 
@@ -407,11 +418,11 @@ enum class FactbookFormattingTag(val type: HtmlLexerTag) {
        ERROR(HtmlTagLexerTag(attributes = mapOf("style" to "color: #f00"), tagCreator = TagConsumer<*>::div.toTagCreator())),
        
        H1(HtmlHeaderLexerTag(tagCreator = TagConsumer<*>::h1.toTagCreator()) { null }),
-       H2(HtmlHeaderLexerTag(tagCreator = TagConsumer<*>::h2.toTagCreator(), ParserTree::treeToAnchorText)),
-       H3(HtmlHeaderLexerTag(tagCreator = TagConsumer<*>::h3.toTagCreator(), ParserTree::treeToAnchorText)),
-       H4(HtmlHeaderLexerTag(tagCreator = TagConsumer<*>::h4.toTagCreator(), ParserTree::treeToAnchorText)),
-       H5(HtmlHeaderLexerTag(tagCreator = TagConsumer<*>::h5.toTagCreator(), ParserTree::treeToAnchorText)),
-       H6(HtmlHeaderLexerTag(tagCreator = TagConsumer<*>::h6.toTagCreator(), ParserTree::treeToAnchorText)),
+       H2(HtmlHeaderLexerTag(tagCreator = TagConsumer<*>::h2.toTagCreator(), String::sanitizeAnchor)),
+       H3(HtmlHeaderLexerTag(tagCreator = TagConsumer<*>::h3.toTagCreator(), String::sanitizeAnchor)),
+       H4(HtmlHeaderLexerTag(tagCreator = TagConsumer<*>::h4.toTagCreator(), String::sanitizeAnchor)),
+       H5(HtmlHeaderLexerTag(tagCreator = TagConsumer<*>::h5.toTagCreator(), String::sanitizeAnchor)),
+       H6(HtmlHeaderLexerTag(tagCreator = TagConsumer<*>::h6.toTagCreator(), String::sanitizeAnchor)),
        
        ALIGN(HtmlTagLexerTag(attributes = ::processAlign, tagMode = HtmlTagMode.ITEM, tagCreator = TagConsumer<*>::div.toTagCreator())),
        ASIDE(HtmlTagLexerTag(attributes = ::processFloat, tagMode = HtmlTagMode.ITEM, tagCreator = TagConsumer<*>::div.toTagCreator())),
@@ -477,16 +488,11 @@ enum class FactbookFormattingTag(val type: HtmlLexerTag) {
        TH(HtmlTagLexerTag(attributes = ::processTableCell, tagMode = HtmlTagMode.ITEM, tagCreator = TagConsumer<*>::th.toTagCreator())),
        
        MOMENT(HtmlTextBodyLexerTag { _, _, content ->
-               val epochMilli = content.toLongOrNull()
-               if (epochMilli == null)
+               val instant = content.toLongOrNull()?.let { Instant.ofEpochMilli(it) }
+               if (instant == null)
                        ({ +content })
                else
-                       ({
-                               span(classes = "moment") {
-                                       style = "display:none"
-                                       +"$epochMilli"
-                               }
-                       })
+                       ({ dateTime(instant) })
        }),
        LINK(HtmlTagLexerTag(attributes = ::processInternalLink, tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::a.toTagCreator())),
        EXTLINK(HtmlTagLexerTag(attributes = ::processExternalLink, tagMode = HtmlTagMode.INLINE, tagCreator = TagConsumer<*>::a.toTagCreator())),
@@ -655,6 +661,18 @@ fun ParserTree.toFactbookHtml(): TagConsumer<*>.() -> Any? {
        ).processTree(this)
 }
 
+class HtmlCommentImageLexerTag(val domain: String) : HtmlLexerTag {
+       override fun processTag(env: LexerTagEnvironment<HtmlBuilderContext, HtmlBuilderSubject>, param: String?, subNodes: ParserTree): HtmlBuilderSubject {
+               val imageUrl = processCommentImage(subNodes.treeToText(), domain)
+               val (width, height) = getSizeParam(param)
+               val sizeStyle = getImageSizeStyleValue(width, height)
+               
+               return {
+                       img(src = imageUrl) { style = sizeStyle }
+               }
+       }
+}
+
 enum class CommentFormattingTag(val type: HtmlLexerTag) {
        B(FactbookFormattingTag.B.type),
        I(FactbookFormattingTag.I.type),
@@ -685,15 +703,7 @@ enum class CommentFormattingTag(val type: HtmlLexerTag) {
        
        LANG(FactbookFormattingTag.LANG.type),
        
-       IMGBB(HtmlTextBodyLexerTag { _, tagParam, content ->
-               val imageUrl = processCommentImage(content, "i.ibb.co")
-               val (width, height) = getSizeParam(tagParam)
-               val sizeStyle = getImageSizeStyleValue(width, height)
-               
-               ({
-                       img(src = imageUrl) { style = sizeStyle }
-               })
-       }),
+       IMGBB(HtmlCommentImageLexerTag("i.ibb.co")),
        
        REPLY(HtmlTextBodyLexerTag { _, _, content ->
                val id = sanitizeId(content)
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ParserJson.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ParserJson.kt
deleted file mode 100644 (file)
index e0344dc..0000000
+++ /dev/null
@@ -1,125 +0,0 @@
-package info.mechyrdia.lore
-
-import info.mechyrdia.route.KeyedEnumSerializer
-import kotlinx.serialization.KSerializer
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.descriptors.PrimitiveKind
-import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
-import kotlinx.serialization.descriptors.SerialDescriptor
-import kotlinx.serialization.encoding.Decoder
-import kotlinx.serialization.encoding.Encoder
-
-@Serializable(with = DocTextColorSerializer::class)
-@JvmInline
-value class DocTextColor(val rgb: Int) {
-       constructor(rgb: String) : this(fromStringOrNull(rgb) ?: error("Expected string of 3 or 6 hex digits with optional # prefix, got $rgb"))
-       
-       override fun toString(): String {
-               return "#${rgb.toString(16).padStart(6, '0')}"
-       }
-       
-       companion object {
-               fun fromStringOrNull(rgb: String): Int? {
-                       return repeatColorDigits(rgb.removePrefix("#"))?.toIntOrNull(16)
-               }
-       }
-}
-
-object DocTextColorSerializer : KSerializer<DocTextColor> {
-       override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("DocTextColorSerializer", PrimitiveKind.STRING)
-       
-       override fun serialize(encoder: Encoder, value: DocTextColor) {
-               encoder.encodeString(value.toString())
-       }
-       
-       override fun deserialize(decoder: Decoder): DocTextColor {
-               return DocTextColor(decoder.decodeString())
-       }
-}
-
-@Serializable(with = DocTextFontSerializer::class)
-enum class DocTextFont {
-       NORMAL, CODE, IPA
-}
-
-object DocTextFontSerializer : KeyedEnumSerializer<DocTextFont>(DocTextFont.entries)
-
-@Serializable
-data class DocTextFormat(
-       val isBold: Boolean = false,
-       val isItalic: Boolean = false,
-       val isUnderline: Boolean = false,
-       val isStrikeOut: Boolean = false,
-       val isSubscript: Boolean = false,
-       val isSuperscript: Boolean = false,
-       val color: DocTextColor? = null,
-       val font: DocTextFont = DocTextFont.NORMAL
-)
-
-@Serializable
-data class DocText(
-       val text: String,
-       val format: DocTextFormat = DocTextFormat(),
-)
-
-@Serializable
-sealed class DocLayoutItem {
-       @Serializable
-       @SerialName("textLine")
-       data class TextLine(val text: List<DocText>) : DocBlock()
-       
-       @Serializable
-       @SerialName("formatBlock")
-       data class FormatBlock(val blocks: List<DocBlock>) : DocBlock()
-}
-
-@Serializable(with = ListingTypeSerializer::class)
-enum class ListingType {
-       ORDERED,
-       UNORDERED,
-}
-
-object ListingTypeSerializer : KeyedEnumSerializer<ListingType>(ListingType.entries)
-
-@Serializable
-@JvmInline
-value class DocTableRow(
-       val cells: List<DocTableCell>
-)
-
-@Serializable
-data class DocTableCell(
-       val isHeading: Boolean = false,
-       val colSpan: Int = 1,
-       val rowSpan: Int = 1,
-       val contents: DocLayoutItem
-)
-
-@Serializable
-sealed class DocBlock {
-       @Serializable
-       @SerialName("paragraph")
-       data class Paragraph(val contents: List<DocText>) : DocBlock()
-       
-       @Serializable
-       @SerialName("list")
-       data class Listing(val ordering: ListingType, val items: List<DocLayoutItem>) : DocBlock()
-       
-       @Serializable
-       @SerialName("table")
-       data class Table(val items: List<DocTableRow>) : DocBlock()
-}
-
-@Serializable
-data class DocSections(
-       val headingText: String,
-       val headContent: List<DocBlock>,
-       val subSections: List<DocSections>,
-)
-
-@Serializable
-data class Document(
-       val ogData: OpenGraphData?,
-       val sections: DocSections,
-)
index db892fd75cc50ce176d00227254d7c3cbc30b97a..a91c8f05737773ac8d6e4e73995321f7f1a62694 100644 (file)
@@ -55,7 +55,7 @@ class PreProcessorContext private constructor(
                
                fun defaults(lorePath: List<String>) = mapOf(
                        PAGE_PATH_KEY to "/${lorePath.joinToString(separator = "/")}".textToTree(),
-                       INSTANT_NOW_KEY to Instant.now().toEpochMilli().toString().textToTree(),
+                       INSTANT_NOW_KEY to Instant.now().toEpochMilli().numberToTree(),
                )
        }
 }
@@ -243,7 +243,7 @@ fun ParserTree.asPreProcessorMap(): Map<String, ParserTree> = mapNotNull {
                it.param to it.subNodes
 }.toMap()
 
-suspend fun <T, R> List<T>.mapSuspend(processor: suspend (T) -> R) = coroutineScope {
+suspend fun <T, R> Iterable<T>.mapSuspend(processor: suspend (T) -> R) = coroutineScope {
        map {
                async {
                        processor(it)
index d7ef1cf1694e1810f02265b695eeb0a3c1a6a1dd..055acfd17c6df9b63a9ef3338afdd76756c02a52 100644 (file)
@@ -44,7 +44,7 @@ object PreProcessorScriptLoader {
                
                val digest = hex(hasher.get().digest(script))
                return withContext(Dispatchers.IO) {
-                       cache.computeIfAbsent(digest) { _ ->
+                       cache.getOrPut(digest) {
                                (scriptEngine.get() as Compilable).compile(String(script))
                        }
                }
index d688147d3b91aa073a7dfaca6c9af3646ca9d7d9..2e6155534a9689595d1cc1d205e78c78d2535a65 100644 (file)
@@ -64,7 +64,7 @@ fun ParserTree.toPreProcessJson(): JsonElement {
 }
 
 object FactbookLoader {
-       suspend fun loadJsonData(lorePath: List<String>): JsonObject {
+       private suspend fun loadJsonData(lorePath: List<String>): JsonObject {
                val jsonPath = lorePath.dropLast(1) + listOf("${lorePath.last()}.json")
                val bytes = FileStorage.instance.readFile(StoragePath.jsonDocDir / jsonPath) ?: return JsonObject(emptyMap())
                return JsonStorageCodec.parseToJsonElement(String(bytes)) as JsonObject
diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ParserRobot.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ParserRobot.kt
new file mode 100644 (file)
index 0000000..3125f36
--- /dev/null
@@ -0,0 +1,217 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.robot.toOpenAiName
+import java.time.Instant
+
+fun String.toRobotUrl(context: RobotTextContext): String {
+       val filePath = if (startsWith("/"))
+               this.removePrefix("/")
+       else
+               context.siblingFile(this).joinToString(separator = "/")
+       
+       return filePath.toOpenAiName()
+}
+
+class RobotTextContext(val currentPath: List<String>) {
+       fun siblingFile(file: String) = currentPath.dropLast(1) + file
+}
+
+typealias RobotTextSubject = String
+
+object RobotTextLexerProcessor : LexerTagFallback<RobotTextContext, RobotTextSubject>, LexerTextProcessor<RobotTextContext, RobotTextSubject>, LexerLineBreakProcessor<RobotTextContext, RobotTextSubject>, LexerCombiner<RobotTextContext, RobotTextSubject> {
+       override fun processInvalidTag(env: LexerTagEnvironment<RobotTextContext, RobotTextSubject>, tag: String, param: String?, subNodes: ParserTree): RobotTextSubject {
+               return env.processTree(subNodes)
+       }
+       
+       override fun processText(env: LexerTagEnvironment<RobotTextContext, RobotTextSubject>, text: String): RobotTextSubject {
+               return text
+       }
+       
+       override fun processLineBreak(env: LexerTagEnvironment<RobotTextContext, RobotTextSubject>): RobotTextSubject {
+               return " "
+       }
+       
+       override fun combine(env: LexerTagEnvironment<RobotTextContext, RobotTextSubject>, subjects: List<RobotTextSubject>): RobotTextSubject {
+               return subjects.joinToString(separator = "")
+       }
+}
+
+fun interface RobotTextTag : LexerTagProcessor<RobotTextContext, RobotTextSubject>
+
+object RobotTextEmptyTag : RobotTextTag {
+       override fun processTag(env: LexerTagEnvironment<RobotTextContext, RobotTextSubject>, param: String?, subNodes: ParserTree): RobotTextSubject {
+               return ""
+       }
+}
+
+enum class FactbookRobotFormattingTag(val type: RobotTextTag) {
+       B(RobotTextTag { env, _, subNodes ->
+               "**${env.processTree(subNodes)}**"
+       }),
+       I(RobotTextTag { env, _, subNodes ->
+               "*${env.processTree(subNodes)}*"
+       }),
+       U(RobotTextTag { env, _, subNodes ->
+               "__${env.processTree(subNodes)}__"
+       }),
+       S(RobotTextTag { env, _, subNodes ->
+               "~~${env.processTree(subNodes)}~~"
+       }),
+       SUP(RobotTextTag { env, _, subNodes ->
+               "^(${env.processTree(subNodes)})"
+       }),
+       SUB(RobotTextTag { env, _, subNodes ->
+               "_(${env.processTree(subNodes)})"
+       }),
+       BLOCKQUOTE(RobotTextTag { env, _, subNodes ->
+               ">>${env.processTree(subNodes)}<<"
+       }),
+       
+       H1(RobotTextTag { env, _, subNodes -> "=${env.processTree(subNodes)}=" }),
+       H2(RobotTextTag { env, _, subNodes -> "==${env.processTree(subNodes)}==" }),
+       H3(RobotTextTag { env, _, subNodes -> "===${env.processTree(subNodes)}===" }),
+       H4(RobotTextTag { env, _, subNodes -> "====${env.processTree(subNodes)}====" }),
+       H5(RobotTextTag { env, _, subNodes -> "=====${env.processTree(subNodes)}=====" }),
+       H6(RobotTextTag { env, _, subNodes -> "======${env.processTree(subNodes)}======" }),
+       
+       THUMB(RobotTextEmptyTag),
+       
+       IMAGE(RobotTextTag { _, _, _ ->
+               "(image)"
+       }),
+       MODEL(RobotTextTag { _, _, _ ->
+               "(3D model)"
+       }),
+       AUDIO(RobotTextTag { _, _, _ ->
+               "(audio)"
+       }),
+       QUIZ(RobotTextTag { _, _, _ ->
+               "(quiz)"
+       }),
+       
+       UL(RobotTextTag { env, _, subNodes ->
+               subNodes
+                       .mapNotNull { subNode ->
+                               if (subNode is ParserTreeNode.Tag && subNode isTag "li")
+                                       " * ${env.processTree(subNode.subNodes)}"
+                               else null
+                       }.joinToString(separator = "")
+       }),
+       OL(RobotTextTag { env, _, subNodes ->
+               subNodes
+                       .mapIndexedNotNull { i, subNode ->
+                               if (subNode is ParserTreeNode.Tag && subNode isTag "li")
+                                       " ${i + 1}. ${env.processTree(subNode.subNodes)}"
+                               else null
+                       }.joinToString(separator = "")
+       }),
+       
+       TABLE(RobotTextTag { env, _, subNodes ->
+               "(table)${env.processTree(subNodes)} ---"
+       }),
+       TR(RobotTextTag { env, _, subNodes ->
+               " ---${env.processTree(subNodes)} |"
+       }),
+       TD(RobotTextTag { env, _, subNodes ->
+               " | ${env.processTree(subNodes)}"
+       }),
+       TH(RobotTextTag { env, _, subNodes ->
+               " | **${env.processTree(subNodes)}**"
+       }),
+       
+       MOMENT(RobotTextTag { env, _, subNodes ->
+               val instant = subNodes.treeToNumberOrNull(String::toLongOrNull)?.let { Instant.ofEpochMilli(it) }
+               instant?.toString() ?: env.processTree(subNodes)
+       }),
+       LINK(RobotTextTag { env, param, subNodes ->
+               env.processTree(subNodes) + param
+                       ?.sanitizeLink()
+                       ?.toRobotUrl(env.context)
+                       ?.let { " <$it>" }
+                       .orEmpty()
+       }),
+       EXTLINK(RobotTextTag { env, param, subNodes ->
+               env.processTree(subNodes) + param
+                       ?.sanitizeLink()
+                       ?.toExternalUrl()
+                       ?.let { " <$it>" }
+                       .orEmpty()
+       }),
+       ANCHOR(RobotTextEmptyTag),
+       REDIRECT(RobotTextTag { env, _, subNodes ->
+               val target = subNodes.treeToText()
+                       .sanitizeLink()
+                       .toRobotUrl(env.context)
+               "(redirect) <$target>"
+       }),
+       LANG(RobotTextTag { env, param, subNodes ->
+               val langName = if ("tylan".equals(param, ignoreCase = true))
+                       "Tylan"
+               else if ("thedish".equals(param, ignoreCase = true))
+                       "Thedish"
+               else if ("kishari".equals(param, ignoreCase = true))
+                       "Kishari"
+               else if ("pokhval".equals(param, ignoreCase = true) || "pochval".equals(param, ignoreCase = true) || "pokhwal".equals(param, ignoreCase = true))
+                       "Pokhwalish"
+               else null
+               val prefix = langName?.let { "(in $it) " }.orEmpty()
+               "$prefix*${env.processTree(subNodes)}*"
+       }),
+       ALPHABET(RobotTextTag { _, param, _ ->
+               if ("mechyrdian".equals(param, ignoreCase = true))
+                       "(preview of Mechyrdia Sans font)"
+               else if ("tylan".equals(param, ignoreCase = true))
+                       "(preview of Tylan abugida font)"
+               else if ("thedish".equals(param, ignoreCase = true))
+                       "(preview of Thedish alphabet font)"
+               else if ("kishari".equals(param, ignoreCase = true))
+                       "(preview of Kishari runic alphabet font)"
+               else if ("pokhval".equals(param, ignoreCase = true) || "pochval".equals(param, ignoreCase = true) || "pokhwal".equals(param, ignoreCase = true))
+                       "(preview of Pokhwalish alphabet font)"
+               else ""
+       }),
+       VOCAB(RobotTextTag { _, _, _ ->
+               "(searchable dictionary of foreign vocabulary)"
+       }),
+       ;
+       
+       companion object {
+               val asTags = LexerTags(entries.associate { it.name to it.type })
+       }
+}
+
+object RobotFactbookLoader {
+       private fun ParserTree.toFactbookRobotText(currentPath: List<String>): String {
+               val context = RobotTextContext(currentPath)
+               val content = LexerTagEnvironment(
+                       context,
+                       FactbookRobotFormattingTag.asTags,
+                       RobotTextLexerProcessor,
+                       RobotTextLexerProcessor,
+                       RobotTextLexerProcessor,
+                       RobotTextLexerProcessor,
+               ).processTree(this)
+               
+               return content
+       }
+       
+       suspend fun loadAllFactbooks(): Map<String, String> {
+               return allPages().mapSuspend { pathStat ->
+                       val lorePath = pathStat.path.elements.drop(1)
+                       FactbookLoader.loadFactbook(lorePath)?.toFactbookRobotText(lorePath)?.let { robotText ->
+                               lorePath.joinToString(separator = "/") to robotText
+                       }
+               }.filterNotNull().toMap()
+       }
+       
+       suspend fun loadAllFactbooksSince(lastUpdated: Instant): Map<String, String> {
+               return allPages().mapSuspend { pathStat ->
+                       if (pathStat.stat.updated >= lastUpdated) {
+                               val lorePath = pathStat.path.elements.drop(1)
+                               FactbookLoader.loadFactbook(lorePath)?.toFactbookRobotText(lorePath)?.let { robotText ->
+                                       lorePath.joinToString(separator = "/") to robotText
+                               }
+                       } else null
+               }.filterNotNull().toMap()
+       }
+}
index bd2187cafc0e8e822e58094db50082a3f3ddd0d3..8c0e1b35aae4d9d384ec16937ccd49467773337d 100644 (file)
@@ -30,7 +30,7 @@ sealed class ParserTreeBuilderState {
        
        private fun endText() {
                if (currentString.isEmpty()) return
-               nodes.add(ParserTreeNode.Text(currentString.toString().replace('\n', ' ')))
+               nodes.add(ParserTreeNode.Text(currentString.toString()))
                currentString.clear()
        }
        
@@ -39,7 +39,7 @@ sealed class ParserTreeBuilderState {
                nodes.add(ParserTreeNode.LineBreak)
        }
        
-       fun endDoc(): ParserTree {
+       open fun endDoc(): ParserTree {
                endText()
                return nodes
        }
@@ -63,123 +63,179 @@ sealed class ParserTreeBuilderState {
                private val tag: String,
                private val param: String? = null
        ) : ParserTreeBuilderState() {
+               override fun endDoc(): ParserTree {
+                       return endTag().endDoc()
+               }
+               
                override fun canEndTag(endTag: String): TreeTag? {
                        return if (tag.equals(endTag, ignoreCase = true)) this else null
                }
                
                fun endTag(): ParserTreeBuilderState {
-                       return parent.doneTag(ParserTreeNode.Tag(tag, param, endDoc()))
+                       return parent.doneTag(ParserTreeNode.Tag(tag, param, super.endDoc()))
                }
        }
 }
 
-sealed class ParserState(
-       protected val builder: ParserTreeBuilderState
+sealed class ParserStreamEvent {
+       data class TagStart(val tag: String, val param: String?) : ParserStreamEvent()
+       data class TagEnd(val tag: String) : ParserStreamEvent()
+       data class CData(val text: String) : ParserStreamEvent()
+       data object ParaBreak : ParserStreamEvent()
+       data object EndOfFile : ParserStreamEvent()
+}
+
+fun interface ParserStreamHandler {
+       fun handleEvent(event: ParserStreamEvent)
+}
+
+class ParserStreamTreeBuilder : ParserStreamHandler {
+       private var builderState: ParserTreeBuilderState? = ParserTreeBuilderState.TreeRoot()
+       private var result: ParserTree? = null
+       
+       fun getAndReset(): ParserTree {
+               val done = result ?: error("Attempting to reset ParserStreamTreeBuilder before document has ended")
+               builderState = ParserTreeBuilderState.TreeRoot()
+               return done
+       }
+       
+       override fun handleEvent(event: ParserStreamEvent) {
+               val state = builderState ?: error("Attempting to use ParserStreamTreeBuilder after document has ended")
+               
+               builderState = when (event) {
+                       is ParserStreamEvent.TagStart -> state
+                               .beginTag(event.tag, event.param)
+                       
+                       is ParserStreamEvent.TagEnd -> state
+                               .canEndTag(event.tag)
+                               ?.endTag()
+                               ?: state.apply { text("[/${event.tag}]") }
+                       
+                       is ParserStreamEvent.CData -> state
+                               .apply { text(event.text) }
+                       
+                       ParserStreamEvent.ParaBreak -> state
+                               .apply { newLine() }
+                       
+                       ParserStreamEvent.EndOfFile -> {
+                               result = state.endDoc()
+                               null
+                       }
+               }
+       }
+}
+
+sealed class ParserState<THandler : ParserStreamHandler, TResult>(
+       protected val handler: THandler,
+       protected val resultGetter: THandler.() -> TResult
 ) {
-       abstract fun processCharacter(char: Char): ParserState
-       open fun processEndOfText(): ParserTree = builder.unwind()
+       abstract fun processCharacter(char: Char): ParserState<THandler, TResult>
+       open fun processEndOfText(): TResult {
+               handler.handleEvent(ParserStreamEvent.EndOfFile)
+               return handler.resultGetter()
+       }
        
-       class Initial : ParserState(ParserTreeBuilderState.TreeRoot()) {
-               override fun processCharacter(char: Char): ParserState {
+       protected fun processedCDataEvent(raw: String) = ParserStreamEvent.CData(raw.replace("\n", ""))
+       
+       class Initial<THandler : ParserStreamHandler, TResult>(handler: THandler, resultGetter: THandler.() -> TResult) : ParserState<THandler, TResult>(handler, resultGetter) {
+               override fun processCharacter(char: Char): ParserState<THandler, TResult> {
                        return if (char == '[')
-                               OpenTag("", builder)
+                               OpenTag("", handler, resultGetter)
                        else
-                               PlainText("$char", builder)
-               }
-               
-               override fun processEndOfText(): ParserTree {
-                       return emptyList()
+                               PlainText("$char", handler, resultGetter)
                }
        }
        
-       class PlainText(private val text: String, builder: ParserTreeBuilderState) : ParserState(builder) {
-               override fun processCharacter(char: Char): ParserState {
+       class PlainText<THandler : ParserStreamHandler, TResult>(private val text: String, handler: THandler, resultGetter: THandler.() -> TResult) : ParserState<THandler, TResult>(handler, resultGetter) {
+               override fun processCharacter(char: Char): ParserState<THandler, TResult> {
                        return if (char == '[') {
-                               builder.text(text)
-                               OpenTag("", builder)
+                               handler.handleEvent(processedCDataEvent(text))
+                               OpenTag("", handler, resultGetter)
                        } else if (char == '\n' && text.endsWith('\n')) {
-                               builder.text(text.removeSuffix("\n"))
-                               builder.newLine()
+                               handler.handleEvent(processedCDataEvent(text))
+                               handler.handleEvent(ParserStreamEvent.ParaBreak)
                                
-                               PlainText("", builder)
-                       } else PlainText("$text$char", builder)
+                               PlainText("", handler, resultGetter)
+                       } else PlainText("$text$char", handler, resultGetter)
                }
                
-               override fun processEndOfText(): ParserTree {
-                       builder.text(text)
+               override fun processEndOfText(): TResult {
+                       handler.handleEvent(processedCDataEvent(text))
                        return super.processEndOfText()
                }
        }
        
-       class NoFormatText(private val text: String, builder: ParserTreeBuilderState) : ParserState(builder) {
-               override fun processCharacter(char: Char): ParserState {
+       class NoFormatText<THandler : ParserStreamHandler, TResult>(private val text: String, handler: THandler, resultGetter: THandler.() -> TResult) : ParserState<THandler, TResult>(handler, resultGetter) {
+               override fun processCharacter(char: Char): ParserState<THandler, TResult> {
                        return if (char == '\n' && text.endsWith('\n')) {
-                               builder.text(text.removeSuffix("\n"))
-                               builder.newLine()
+                               handler.handleEvent(processedCDataEvent(text))
+                               handler.handleEvent(ParserStreamEvent.ParaBreak)
                                
-                               NoFormatText("", builder)
+                               NoFormatText("", handler, resultGetter)
                        } else {
                                val newText = "$text$char"
                                val endTag = "[/$NO_FORMAT_TAG]"
                                if (newText.endsWith(endTag, ignoreCase = true)) {
-                                       builder.text(newText.substring(0, newText.length - endTag.length))
-                                       PlainText("", builder)
-                               } else NoFormatText(newText, builder)
+                                       handler.handleEvent(processedCDataEvent(newText.dropLast(endTag.length)))
+                                       PlainText("", handler, resultGetter)
+                               } else NoFormatText(newText, handler, resultGetter)
                        }
                }
                
-               override fun processEndOfText(): ParserTree {
-                       builder.text(text)
+               override fun processEndOfText(): TResult {
+                       handler.handleEvent(processedCDataEvent(text))
                        return super.processEndOfText()
                }
        }
        
-       class OpenTag(private val tagName: String, builder: ParserTreeBuilderState) : ParserState(builder) {
-               override fun processCharacter(char: Char): ParserState {
+       class OpenTag<THandler : ParserStreamHandler, TResult>(private val tagName: String, handler: THandler, resultGetter: THandler.() -> TResult) : ParserState<THandler, TResult>(handler, resultGetter) {
+               override fun processCharacter(char: Char): ParserState<THandler, TResult> {
                        return if (char == ']') {
                                if (tagName.equals(NO_FORMAT_TAG, ignoreCase = true))
-                                       NoFormatText("", builder)
-                               else
-                                       PlainText("", builder.beginTag(tagName, null))
+                                       NoFormatText("", handler, resultGetter)
+                               else {
+                                       handler.handleEvent(ParserStreamEvent.TagStart(tagName, null))
+                                       PlainText("", handler, resultGetter)
+                               }
                        } else if (char == '=')
-                               TagParam(tagName, "", builder)
+                               TagParam(tagName, "", handler, resultGetter)
                        else if (char == '/' && tagName.isEmpty())
-                               CloseTag("", builder)
+                               CloseTag("", handler, resultGetter)
                        else
-                               OpenTag("$tagName$char", builder)
+                               OpenTag("$tagName$char", handler, resultGetter)
                }
                
-               override fun processEndOfText(): ParserTree {
-                       builder.text("[$tagName")
+               override fun processEndOfText(): TResult {
+                       handler.handleEvent(processedCDataEvent("[$tagName"))
                        return super.processEndOfText()
                }
        }
        
-       class TagParam(private val tagName: String, private val tagParam: String, builder: ParserTreeBuilderState) : ParserState(builder) {
-               override fun processCharacter(char: Char): ParserState {
-                       return if (char == ']')
-                               PlainText("", builder.beginTag(tagName, tagParam))
-                       else
-                               TagParam(tagName, "$tagParam$char", builder)
+       class TagParam<THandler : ParserStreamHandler, TResult>(private val tagName: String, private val tagParam: String, handler: THandler, resultGetter: THandler.() -> TResult) : ParserState<THandler, TResult>(handler, resultGetter) {
+               override fun processCharacter(char: Char): ParserState<THandler, TResult> {
+                       return if (char == ']') {
+                               handler.handleEvent(ParserStreamEvent.TagStart(tagName, tagParam))
+                               PlainText("", handler, resultGetter)
+                       } else
+                               TagParam(tagName, "$tagParam$char", handler, resultGetter)
                }
                
-               override fun processEndOfText(): ParserTree {
-                       builder.text("[$tagName=$tagParam")
+               override fun processEndOfText(): TResult {
+                       handler.handleEvent(processedCDataEvent("[$tagName=$tagParam"))
                        return super.processEndOfText()
                }
        }
        
-       class CloseTag(private val tagName: String, builder: ParserTreeBuilderState) : ParserState(builder) {
-               override fun processCharacter(char: Char): ParserState {
-                       return if (char == ']')
-                               builder.canEndTag(tagName)?.endTag()?.let {
-                                       PlainText("", it)
-                               } ?: PlainText("[/$tagName]", builder)
-                       else CloseTag("$tagName$char", builder)
+       class CloseTag<THandler : ParserStreamHandler, TResult>(private val tagName: String, handler: THandler, resultGetter: THandler.() -> TResult) : ParserState<THandler, TResult>(handler, resultGetter) {
+               override fun processCharacter(char: Char): ParserState<THandler, TResult> {
+                       return if (char == ']') {
+                               handler.handleEvent(ParserStreamEvent.TagEnd(tagName))
+                               PlainText("", handler, resultGetter)
+                       } else CloseTag("$tagName$char", handler, resultGetter)
                }
                
-               override fun processEndOfText(): ParserTree {
-                       builder.text("[/$tagName")
+               override fun processEndOfText(): TResult {
+                       handler.handleEvent(processedCDataEvent("[/$tagName"))
                        return super.processEndOfText()
                }
        }
@@ -187,18 +243,15 @@ sealed class ParserState(
        companion object {
                const val NO_FORMAT_TAG = "noformat"
                
-               private fun ParserTreeBuilderState.unwind(): ParserTree {
-                       return when (this) {
-                               is ParserTreeBuilderState.TreeRoot -> endDoc()
-                               is ParserTreeBuilderState.TreeTag -> endTag().unwind()
-                       }
-               }
-               
                fun parseText(text: String): ParserTree {
                        val fixedText = text.replace("\r\n", "\n").replace('\r', '\n')
-                       return fixedText.fold<ParserState>(Initial()) { state, char ->
+                       return fixedText.fold(TreeParserState()) { state, char ->
                                state.processCharacter(char)
                        }.processEndOfText()
                }
        }
 }
+
+fun TreeParserState(): TreeParserState = ParserState.Initial(ParserStreamTreeBuilder(), ParserStreamTreeBuilder::getAndReset)
+
+typealias TreeParserState = ParserState<ParserStreamTreeBuilder, ParserTree>
index 888c82b550e0d2abc512b8108de7c10654cd558d..dd906fb6b9377e31606d915775684adf17377baa 100644 (file)
@@ -5,6 +5,8 @@ import info.mechyrdia.OwnerNationId
 import info.mechyrdia.data.FileStorage
 import info.mechyrdia.data.StoragePath
 import info.mechyrdia.data.currentNation
+import info.mechyrdia.robot.RobotService
+import info.mechyrdia.robot.RobotServiceStatus
 import info.mechyrdia.route.Root
 import info.mechyrdia.route.createCsrfToken
 import info.mechyrdia.route.href
@@ -40,7 +42,9 @@ suspend fun ApplicationCall.standardNavBar(path: List<String>? = null) = listOf(
                NavLink(href(Root.LorePage(subPath)), (StoragePath.articleDir / subPath).toFriendlyPageTitle())
        }
 }.orEmpty() + (currentNation()?.let { data ->
-       listOf(
+       (if (RobotService.status == RobotServiceStatus.READY)
+               listOf(NavLink(href(Root.Nuke()), "NUKE"))
+       else emptyList()) + listOf(
                NavHead(data.name),
                NavLink(href(Root.User()), "Your User Page"),
                NavLink("https://www.nationstates.net/nation=${data.id}", "Your NationStates Page"),
index 3618db1e6f46f0401fa2f1b46bc375ca2da785ac..c5ac24424b8e47ce8c77f993286bc550662ee57e 100644 (file)
@@ -217,9 +217,12 @@ fun ApplicationCall.adminPage(pageTitle: String, content: BODY.() -> Unit): HTML
        }
 }
 
-fun FlowOrPhrasingContent.dateTime(instant: Instant) {
-       span(classes = "moment") {
-               style = "display:none"
-               +"${instant.toEpochMilli()}"
-       }
+fun FlowOrPhrasingContent.dateTime(instant: Instant) = span(classes = "moment") {
+       style = "display:none"
+       +instant.toEpochMilli().toString()
+}
+
+fun <T, C : TagConsumer<T>> C.dateTime(instant: Instant) = span(classes = "moment") {
+       style = "display:none"
+       +instant.toEpochMilli().toString()
 }
index 3d4bded19fb1cf47f316655704ed244d4e55f8a0..7f9ebfbb9a1d57ea76bdc7c8da982c698a283483 100644 (file)
@@ -40,8 +40,8 @@ suspend fun ApplicationCall.error403(): HTML.() -> Unit = errorPage("403 Forbidd
        p { +"You are not allowed to do that." }
 }
 
-suspend fun ApplicationCall.error403PageExpired(payload: CsrfProtectedResourcePayload): HTML.() -> Unit = errorPage("Page Expired") {
-       with(payload) { displayRetryData() }
+suspend fun ApplicationCall.error403PageExpired(payload: CsrfProtectedResourcePayload?): HTML.() -> Unit = errorPage("Page Expired") {
+       payload?.apply { displayRetryData() }
        p {
                +"The page you were on has expired."
                request.header(HttpHeaders.Referrer)?.let { referrer ->
index deb5566031aaabff521deca3864eaa56837824d9..2f516b277d015c35d2ed2af2767053d5c0555a96 100644 (file)
@@ -22,11 +22,27 @@ suspend fun ApplicationCall.respondRss(rss: RssChannel) {
 
 data class StoragePathWithStat(val path: StoragePath, val stat: StoredFileStats)
 
+private fun StoragePath.rebase(onto: StoragePath) = onto / elements.drop(onto.elements.size)
+
+private suspend fun statAll(paths: Iterable<StoragePath>): StoredFileStats? {
+       val stats = paths.mapSuspend { path ->
+               FileStorage.instance.statFile(path)
+       }.filterNotNull()
+       if (stats.isEmpty()) return null
+       
+       return StoredFileStats(
+               created = stats.minOf { it.created },
+               updated = stats.maxOf { it.updated },
+               size = stats.sumOf { it.size }
+       )
+}
+
 private suspend fun ArticleNode.getPages(base: StoragePath): List<StoragePathWithStat> {
        if (!this.isViewable)
                return emptyList()
        val path = base / name
-       val stat = FileStorage.instance.statFile(path)
+       val dataPath = path.rebase(StoragePath.jsonDocDir)
+       val stat = statAll(listOf(path, dataPath))
        return if (stat != null)
                listOf(StoragePathWithStat(path, stat))
        else if (subNodes != null) coroutineScope {
diff --git a/src/jvmMain/kotlin/info/mechyrdia/robot/RobotApi.kt b/src/jvmMain/kotlin/info/mechyrdia/robot/RobotApi.kt
new file mode 100644 (file)
index 0000000..becd998
--- /dev/null
@@ -0,0 +1,106 @@
+package info.mechyrdia.robot
+
+import io.ktor.client.*
+import io.ktor.client.call.*
+import io.ktor.client.request.*
+import io.ktor.client.request.forms.*
+import io.ktor.http.*
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+
+@JvmInline
+value class RobotClient(
+       private val client: HttpClient
+) {
+       suspend fun uploadFile(purpose: String, file: FileUpload) = client.submitFormWithBinaryData(
+               "https://api.openai.com/v1/files",
+               formData {
+                       append("purpose", purpose)
+                       upload("file", file)
+               }
+       ) {
+               attributes.addTokens(file)
+       }.body<RobotFile>()
+       
+       suspend fun listFiles(purpose: String? = null) = client.get("https://api.openai.com/v1/files" + parameters {
+               purpose?.let { append("purpose", it) }
+       }.toQueryString()).body<RobotFileList>()
+       
+       suspend fun getFile(fileId: RobotFileId) = client.get(
+               "https://api.openai.com/v1/files/${fileId.id}"
+       ).body<RobotFile>()
+       
+       suspend fun deleteFile(fileId: RobotFileId) = client.delete(
+               "https://api.openai.com/v1/files/${fileId.id}"
+       ).body<RobotFileDeletionResponse>()
+       
+       suspend fun downloadFile(fileId: RobotFileId) = client.delete(
+               "https://api.openai.com/v1/files/${fileId.id}"
+       ).body<ByteArray>()
+       
+       suspend fun createVectorStore(request: RobotCreateVectorStoreRequest) = client.post("https://api.openai.com/v1/vector_stores") {
+               setJsonBody(request)
+       }.body<RobotVectorStore>()
+       
+       suspend fun addFileToVectorStore(vsId: RobotVectorStoreId, fileId: RobotFileId) = client.post("https://api.openai.com/v1/vector_stores/${vsId.id}") {
+               setJsonBody(RobotAddFileToVectorStoreRequest(fileId))
+       }.body<RobotVectorStoreFile>()
+       
+       suspend fun listVectorStores(limit: Int? = null, after: RobotVectorStoreId? = null) = client.get("https://api.openai.com/v1/vector_stores" + parameters {
+               limit?.let { append("limit", it.toString()) }
+               after?.let { append("after", it.id) }
+       }.toQueryString()).body<RobotVectorStoreList>()
+       
+       suspend fun getVectorStore(vsId: RobotVectorStoreId) = client.get("https://api.openai.com/v1/vector_stores/${vsId.id}").body<RobotVectorStore>()
+       
+       suspend fun modifyVectorStore(vsId: RobotVectorStoreId, request: RobotModifyVectorStoreRequest) = client.post("https://api.openai.com/v1/vector_stores/${vsId.id}") {
+               setJsonBody(request)
+       }.body<RobotVectorStore>()
+       
+       suspend fun deleteVectorStore(vsId: RobotVectorStoreId) = client.delete("https://api.openai.com/v1/vector_stores/${vsId.id}").body<RobotVectorStoreDeletionResponse>()
+       
+       suspend fun createAssistant(request: RobotCreateAssistantRequest) = client.post("https://api.openai.com/v1/assistants") {
+               setJsonBody(request)
+       }.body<RobotAssistant>()
+       
+       suspend fun listAssistants(limit: Int? = null, after: RobotAssistantId? = null) = client.post("https://api.openai.com/v1/assistants" + parameters {
+               limit?.let { append("limit", it.toString()) }
+               after?.let { append("after", it.id) }
+       }.toQueryString()).body<RobotAssistantList>()
+       
+       suspend fun getAssistant(assistId: RobotAssistantId) = client.get("https://api.openai.com/v1/assistants/${assistId.id}").body<RobotAssistant>()
+       
+       suspend fun deleteAssistant(assistId: RobotAssistantId) = client.delete("https://api.openai.com/v1/assistants/${assistId.id}").body<RobotAssistantDeletionResponse>()
+       
+       suspend fun createThread(request: RobotCreateThreadRequest) = client.post("https://api.openai.com/v1/threads") {
+               setJsonBody(request)
+               attributes.addTokens(request)
+       }.body<RobotThread>()
+       
+       suspend fun getThread(threadId: RobotThreadId) = client.get("https://api.openai.com/v1/threads/${threadId.id}").body<RobotThread>()
+       
+       suspend fun deleteThread(threadId: RobotThreadId) = client.delete("https://api.openai.com/v1/threads/${threadId.id}").body<RobotThreadDeletionResponse>()
+       
+       suspend fun createRun(threadId: RobotThreadId, assistId: RobotAssistantId, messages: List<RobotCreateThreadRequestMessage>): Flow<ServerSentEvent> = client.postSse("https://api.openai.com/v1/threads/${threadId.id}/runs") {
+               val request = RobotCreateRunRequest(assistantId = assistId, additionalMessages = messages, stream = true)
+               setJsonBody(request)
+               attributes.addTokens(request)
+       }
+}
+
+inline fun <reified T> HttpRequestBuilder.setJsonBody(body: T) {
+       contentType(ContentType.Application.Json)
+       setBody(body)
+}
+
+suspend inline fun poll(wait: Long = 1_000L, block: () -> Boolean) {
+       while (!block())
+               delay(wait)
+}
+
+suspend inline fun <T : Any> pollValue(wait: Long = 1_000L, block: () -> T?): T {
+       while (true) {
+               block()?.let { return it }
+               delay(wait)
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/robot/RobotCodec.kt b/src/jvmMain/kotlin/info/mechyrdia/robot/RobotCodec.kt
new file mode 100644 (file)
index 0000000..b67af68
--- /dev/null
@@ -0,0 +1,21 @@
+package info.mechyrdia.robot
+
+import io.ktor.http.*
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.json.Json
+
+@OptIn(ExperimentalSerializationApi::class)
+val JsonRobotCodec = Json {
+       coerceInputValues = true
+       ignoreUnknownKeys = true
+       useAlternativeNames = false
+       decodeEnumsCaseInsensitive = true
+}
+
+fun Parameters.toQueryString(): String {
+       val formEncoded = formUrlEncode()
+       return if (formEncoded.isEmpty())
+               formEncoded
+       else
+               "?$formEncoded"
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/robot/RobotFiles.kt b/src/jvmMain/kotlin/info/mechyrdia/robot/RobotFiles.kt
new file mode 100644 (file)
index 0000000..f274ae6
--- /dev/null
@@ -0,0 +1,24 @@
+package info.mechyrdia.robot
+
+import io.ktor.client.request.forms.*
+import io.ktor.http.*
+
+class FileUpload(
+       val content: ByteArray,
+       val contentType: ContentType,
+       val contentName: String,
+) : Tokenizable {
+       override fun getTexts(): List<String> {
+               return if (contentType.match(ContentType.Text.Any))
+                       listOf(String(content))
+               else emptyList()
+       }
+}
+
+fun FormBuilder.upload(key: String, file: FileUpload) = append(key, file.content, Headers.build {
+       append(HttpHeaders.ContentType, file.contentType)
+       append(HttpHeaders.ContentDisposition, "filename=\"${file.contentName}\"")
+})
+
+fun String.toOpenAiName() = replace('.', '_') + ".txt"
+fun String.fromOpenAiName() = removeSuffix(".txt").replace('_', '.')
diff --git a/src/jvmMain/kotlin/info/mechyrdia/robot/RobotRateLimiter.kt b/src/jvmMain/kotlin/info/mechyrdia/robot/RobotRateLimiter.kt
new file mode 100644 (file)
index 0000000..c4f2f4d
--- /dev/null
@@ -0,0 +1,107 @@
+package info.mechyrdia.robot
+
+import com.aallam.ktoken.Encoding
+import com.aallam.ktoken.Tokenizer
+import io.ktor.client.plugins.api.*
+import io.ktor.util.*
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import java.time.Instant
+import java.util.concurrent.atomic.AtomicInteger
+import java.util.concurrent.atomic.AtomicLong
+import kotlin.random.Random
+import kotlin.time.Duration.Companion.seconds
+
+private val DurationRegex = Regex("([0-9]+h)?([0-9]+m)?([0-9]+s)?")
+
+private fun String.parseDurationToSeconds(): Int {
+       val durationMatch = DurationRegex.matchEntire(this) ?: return 0
+       val (hoursStr, minutesStr, secondsStr) = durationMatch.destructured
+       
+       val hours = if (hoursStr.endsWith("h")) hoursStr.dropLast(1).toInt() else 0
+       val minutes = if (minutesStr.endsWith("m")) minutesStr.dropLast(1).toInt() else 0
+       val seconds = if (secondsStr.endsWith("s")) secondsStr.dropLast(1).toInt() else 0
+       
+       return (hours * 3600) + (minutes * 60) + seconds
+}
+
+private fun Int.secondFromNow() = Instant.now().epochSecond + this
+
+private fun calculateRateLimitDelayDouble(requestsRemaining: Int, requestsResetAt: Long): Double? {
+       val now = Instant.now().epochSecond
+       if (requestsRemaining > 0 && requestsResetAt <= now)
+               return null
+       
+       return requestsResetAt - now + 0.5
+}
+
+private fun combineDelays(vararg delays: Double?) = if (delays.all { it == null })
+       null
+else delays.sumOf { it ?: 0.0 } + Random.nextDouble(0.25, 0.75)
+
+val RobotRateLimiter = createClientPlugin("RobotRateLimiter") {
+       val requestsRemaining = AtomicInteger(1)
+       val requestsResetAt = AtomicLong(0)
+       
+       val tokensRemaining = AtomicInteger(1)
+       val tokensResetAt = AtomicLong(0)
+       
+       onRequest { request, _ ->
+               val requestDelay = calculateRateLimitDelayDouble(requestsRemaining.getAcquire(), requestsResetAt.getAcquire())
+               val tokenDelay = request.attributes.getTokens()?.let { _ ->
+                       calculateRateLimitDelayDouble(tokensRemaining.getAcquire(), tokensResetAt.getAcquire())
+               }
+               
+               combineDelays(requestDelay, tokenDelay)?.seconds?.let { delay(it) }
+       }
+       
+       @Suppress("UastIncorrectHttpHeaderInspection")
+       onResponse { response ->
+               val newRequestsRemaining = response.headers["X-Ratelimit-Remaining-Requests"]?.toIntOrNull() ?: -1
+               val newRequestsResetAt = response.headers["X-Ratelimit-Reset-Requests"]?.parseDurationToSeconds()?.secondFromNow() ?: 0
+               val newTokensRemaining = response.headers["X-Ratelimit-Remaining-Tokens"]?.toIntOrNull() ?: -1
+               val newTokensResetAt = response.headers["X-Ratelimit-Reset-Tokens"]?.parseDurationToSeconds()?.secondFromNow() ?: 0
+               
+               requestsRemaining.setRelease(newRequestsRemaining)
+               requestsResetAt.setRelease(newRequestsResetAt)
+               tokensRemaining.setRelease(newTokensRemaining)
+               tokensResetAt.setRelease(newTokensResetAt)
+       }
+}
+
+private val RobotTokenCountKey = AttributeKey<Int>("Mechyrdia.RobotTokenCount")
+
+suspend fun Attributes.addTokens(tokenizable: Tokenizable) {
+       val deltaCount = tokenizable.getTexts().countTokens()
+       put(RobotTokenCountKey, deltaCount + computeIfAbsent(RobotTokenCountKey) { 0 })
+}
+
+fun Attributes.getTokens(): Int? = getOrNull(RobotTokenCountKey)
+
+private var tokenizerStore: Tokenizer? = null
+private val tokenizerMutex = Mutex()
+
+suspend fun getTokenizer(): Tokenizer {
+       return tokenizerStore ?: tokenizerMutex.withLock {
+               Tokenizer.of(Encoding.CL100K_BASE).also {
+                       tokenizerStore = it
+               }
+       }
+}
+
+fun interface Tokenizable {
+       fun getTexts(): List<String>
+}
+
+fun List<Tokenizable>.flatten() = Tokenizable {
+       flatMap { it.getTexts() }
+}
+
+suspend fun String.countTokens(): Int {
+       return getTokenizer().encode(this).size
+}
+
+suspend fun List<String>.countTokens(): Int {
+       return sumOf { it.countTokens() }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/robot/RobotSchema.kt b/src/jvmMain/kotlin/info/mechyrdia/robot/RobotSchema.kt
new file mode 100644 (file)
index 0000000..99c8cd0
--- /dev/null
@@ -0,0 +1,266 @@
+package info.mechyrdia.robot
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+@JvmInline
+value class RobotFileId(val id: String)
+
+@Serializable
+data class RobotFile(
+       val id: RobotFileId,
+       val bytes: Long,
+       @SerialName("created_at")
+       val createdAt: Long,
+       val filename: String,
+       val purpose: String,
+)
+
+@Serializable
+data class RobotFileList(
+       val data: List<RobotFile>,
+)
+
+@Serializable
+data class RobotFileDeletionResponse(
+       val id: RobotFileId,
+       val deleted: Boolean,
+)
+
+@Serializable
+data class RobotCreateVectorStoreRequest(
+       val name: String? = null,
+       @SerialName("file_ids")
+       val fileIds: List<RobotFileId> = emptyList(),
+)
+
+@Serializable
+data class RobotModifyVectorStoreRequest(
+       val name: String? = null,
+)
+
+@Serializable
+@JvmInline
+value class RobotVectorStoreId(val id: String)
+
+@Serializable
+data class RobotVectorStoreFileCounts(
+       @SerialName("in_progress")
+       val inProgress: Int = 0,
+       val completed: Int = 0,
+       val failed: Int = 0,
+       val cancelled: Int = 0,
+       val total: Int = 0,
+)
+
+@Serializable
+data class RobotVectorStore(
+       val id: RobotVectorStoreId,
+       val name: String,
+       @SerialName("created_at")
+       val createdAt: Long,
+       val bytes: Long = 0L,
+       @SerialName("file_counts")
+       val fileCounts: RobotVectorStoreFileCounts = RobotVectorStoreFileCounts(),
+       val status: String,
+)
+
+@Serializable
+data class RobotVectorStoreList(
+       val data: List<RobotVectorStore>,
+       @SerialName("first_id")
+       val firstId: RobotVectorStoreId? = null,
+       @SerialName("last_id")
+       val lastId: RobotVectorStoreId? = null,
+       @SerialName("has_more")
+       val hasMore: Boolean = false,
+)
+
+@Serializable
+data class RobotVectorStoreDeletionResponse(
+       val id: RobotVectorStoreId,
+       val deleted: Boolean,
+)
+
+@Serializable
+data class RobotAddFileToVectorStoreRequest(
+       val fileId: RobotFileId,
+)
+
+@Serializable
+data class RobotVectorStoreFile(
+       val id: RobotFileId,
+       @SerialName("created_at")
+       val createdAt: Long,
+       @SerialName("vector_store_id")
+       val vectorStoreId: RobotVectorStoreId,
+       val status: String,
+)
+
+@Serializable
+data class RobotCreateAssistantRequestTool(
+       val type: String,
+)
+
+@Serializable
+data class RobotCreateAssistantRequestFileSearchResources(
+       @SerialName("vector_store_ids")
+       val vectorStoreIds: List<RobotVectorStoreId>? = null,
+)
+
+@Serializable
+data class RobotCreateAssistantRequestToolResources(
+       @SerialName("file_search")
+       val fileSearch: RobotCreateAssistantRequestFileSearchResources? = null
+)
+
+@Serializable
+data class RobotCreateAssistantRequest(
+       val model: String,
+       val name: String? = null,
+       val description: String? = null,
+       val instructions: String? = null,
+       val tools: List<RobotCreateAssistantRequestTool>? = null,
+       @SerialName("tool_resources")
+       val toolResources: RobotCreateAssistantRequestToolResources? = null,
+       val temperature: Double? = null,
+)
+
+@Serializable
+@JvmInline
+value class RobotAssistantId(val id: String)
+
+@Serializable
+data class RobotAssistant(
+       val id: RobotAssistantId,
+       @SerialName("created_at")
+       val createdAt: Long,
+       val model: String,
+       val name: String? = null,
+       val description: String? = null,
+       val instructions: String? = null,
+       val tools: List<RobotCreateAssistantRequestTool>? = null,
+       @SerialName("tool_resources")
+       val toolResources: RobotCreateAssistantRequestToolResources,
+       val temperature: Double? = null,
+       @SerialName("top_p")
+       val topP: Double? = null,
+)
+
+@Serializable
+data class RobotAssistantList(
+       val data: List<RobotAssistant>,
+       @SerialName("first_id")
+       val firstId: RobotAssistantId? = null,
+       @SerialName("last_id")
+       val lastId: RobotAssistantId? = null,
+       @SerialName("has_more")
+       val hasMore: Boolean = false,
+)
+
+@Serializable
+data class RobotAssistantDeletionResponse(
+       val id: RobotAssistantId,
+       val deleted: Boolean,
+)
+
+@Serializable
+data class RobotCreateThreadRequestMessage(
+       val role: String,
+       val content: String,
+) : Tokenizable {
+       override fun getTexts(): List<String> {
+               return listOf(content)
+       }
+}
+
+@Serializable
+data class RobotCreateThreadRequest(
+       val messages: List<RobotCreateThreadRequestMessage> = emptyList(),
+) : Tokenizable by messages.flatten()
+
+@Serializable
+@JvmInline
+value class RobotThreadId(val id: String)
+
+@Serializable
+data class RobotThread(
+       val id: RobotThreadId,
+       @SerialName("created_at")
+       val createdAt: Long,
+)
+
+@Serializable
+data class RobotThreadDeletionResponse(
+       val id: RobotThreadId,
+       val deleted: Boolean,
+)
+
+@Serializable
+data class RobotCreateRunRequest(
+       @SerialName("assistant_id")
+       val assistantId: RobotAssistantId,
+       @SerialName("additional_messages")
+       val additionalMessages: List<RobotCreateThreadRequestMessage> = emptyList(),
+       val stream: Boolean,
+) : Tokenizable by additionalMessages.flatten()
+
+@Serializable
+@JvmInline
+value class RobotMessageId(val id: String)
+
+@Serializable
+data class RobotFileCitation(
+       @SerialName("file_id")
+       val fileId: RobotFileId,
+       val quote: String,
+)
+
+@Serializable
+data class RobotMessageTextAnnotation(
+       val index: Int = 0,
+       val text: String,
+       @SerialName("file_citation")
+       val fileCitation: RobotFileCitation,
+       @SerialName("start_index")
+       val startIndex: Int = 0,
+       @SerialName("end_index")
+       val endIndex: Int = 0,
+)
+
+@Serializable
+data class RobotMessageText(
+       val value: String = "",
+       val annotations: List<RobotMessageTextAnnotation> = emptyList(),
+)
+
+@Serializable
+data class RobotMessageDeltaText(
+       val index: Int = 0,
+       val text: RobotMessageText = RobotMessageText(),
+)
+
+@Serializable
+data class RobotMessageDeltaFields(
+       val role: String? = null,
+       val content: List<RobotMessageDeltaText> = emptyList(),
+)
+
+@Serializable
+data class RobotMessageDelta(
+       val id: RobotMessageId,
+       val delta: RobotMessageDeltaFields = RobotMessageDeltaFields()
+)
+
+@Serializable
+data class RobotMessage(
+       val id: RobotMessageId,
+       @SerialName("created_at")
+       val createdAt: Long,
+       @SerialName("thread_id")
+       val threadId: RobotThreadId,
+       val status: String,
+       val role: String,
+       val content: List<RobotMessageText> = emptyList(),
+)
diff --git a/src/jvmMain/kotlin/info/mechyrdia/robot/RobotService.kt b/src/jvmMain/kotlin/info/mechyrdia/robot/RobotService.kt
new file mode 100644 (file)
index 0000000..230e6ab
--- /dev/null
@@ -0,0 +1,362 @@
+package info.mechyrdia.robot
+
+import info.mechyrdia.Configuration
+import info.mechyrdia.MainDomainName
+import info.mechyrdia.data.*
+import info.mechyrdia.lore.RobotFactbookLoader
+import io.ktor.client.*
+import io.ktor.client.engine.java.*
+import io.ktor.client.plugins.*
+import io.ktor.client.plugins.contentnegotiation.*
+import io.ktor.client.plugins.logging.*
+import io.ktor.client.request.*
+import io.ktor.http.*
+import io.ktor.serialization.kotlinx.json.*
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.*
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+import java.time.Instant
+import kotlin.random.Random
+import kotlin.time.Duration.Companion.minutes
+
+val RobotGlobalsId = Id<RobotGlobals>("RobotGlobalsInstance")
+
+@Serializable
+data class RobotGlobals(
+       @SerialName(MONGODB_ID_KEY)
+       override val id: Id<RobotGlobals> = RobotGlobalsId,
+       
+       val lastFileUpload: @Serializable(with = InstantNullableSerializer::class) Instant? = null,
+       val fileIdMap: Map<String, RobotFileId> = emptyMap(),
+       val vectorStoreId: RobotVectorStoreId? = null,
+       val assistantId: RobotAssistantId? = null,
+       val ongoingThreadIds: Set<RobotThreadId> = emptySet(),
+) : DataDocument<RobotGlobals> {
+       suspend fun save(): RobotGlobals {
+               set(this)
+               return this
+       }
+       
+       companion object : TableHolder<RobotGlobals> {
+               override val Table = DocumentTable<RobotGlobals>()
+               
+               suspend fun get() = Table.get(RobotGlobalsId)
+               suspend fun set(instance: RobotGlobals) = Table.put(instance)
+               
+               override suspend fun initialize() = Unit
+       }
+}
+
+private fun RobotGlobals.plusThread(threadId: RobotThreadId) = copy(
+       ongoingThreadIds = ongoingThreadIds + threadId
+)
+
+private fun RobotGlobals.minusThread(threadId: RobotThreadId) = copy(
+       ongoingThreadIds = ongoingThreadIds - threadId
+)
+
+enum class RobotServiceStatus {
+       NOT_CONFIGURED,
+       LOADING,
+       FAILED,
+       READY,
+}
+
+class RobotService(
+       token: String,
+       orgId: String,
+       project: String?,
+) {
+       private val robotClient = RobotClient(
+               HttpClient(Java) {
+                       defaultRequest {
+                               header(HttpHeaders.Authorization, "Bearer $token")
+                               header("OpenAI-Organization", orgId)
+                               project?.let { header("OpenAI-Project", it) }
+                               header("OpenAI-Beta", "assistants=v2")
+                       }
+                       
+                       install(ContentNegotiation) {
+                               json(JsonRobotCodec)
+                       }
+                       
+                       Logging {
+                               level = LogLevel.INFO
+                               sanitizeHeader("<OPENAI TOKEN>") { it == HttpHeaders.Authorization }
+                       }
+                       
+                       install(HttpRequestRetry) {
+                               retryOnExceptionOrServerErrors(5)
+                               delayMillis { retry ->
+                                       (1 shl (retry - 1)) * 1000L + Random.nextLong(250L, 750L)
+                               }
+                       }
+                       
+                       expectSuccess = true
+                       
+                       install(RobotRateLimiter)
+               }
+       )
+       
+       private suspend fun createThread(): RobotThreadId {
+               return robotClient.createThread(RobotCreateThreadRequest()).id.also { threadId ->
+                       (RobotGlobals.get() ?: RobotGlobals()).plusThread(threadId).save()
+               }
+       }
+       
+       private suspend fun deleteThread(threadId: RobotThreadId) {
+               robotClient.deleteThread(threadId)
+               (RobotGlobals.get() ?: RobotGlobals()).minusThread(threadId).save()
+       }
+       
+       private suspend fun RobotGlobals.gcOldThreads(): RobotGlobals {
+               for (threadId in ongoingThreadIds)
+                       robotClient.deleteThread(threadId)
+               return copy(ongoingThreadIds = emptySet())
+       }
+       
+       private suspend fun updateFiles(prevGlobals: RobotGlobals?, onNewFileId: (suspend (RobotFileId) -> Unit)? = null): RobotGlobals {
+               val robotGlobals = prevGlobals ?: RobotGlobals()
+               
+               val fileIdMap = buildMap<String, RobotFileId> {
+                       putAll(robotGlobals.fileIdMap)
+                       
+                       val factbooks = robotGlobals.lastFileUpload?.let {
+                               RobotFactbookLoader.loadAllFactbooksSince(it)
+                       } ?: RobotFactbookLoader.loadAllFactbooks()
+                       
+                       for ((name, text) in factbooks) {
+                               remove(name)?.let { oldId ->
+                                       robotClient.deleteFile(oldId)
+                               }
+                               
+                               val newId = robotClient.uploadFile(
+                                       "assistants",
+                                       FileUpload(
+                                               text.toByteArray(),
+                                               ContentType.Text.Plain.withCharset(Charsets.UTF_8),
+                                               name.toOpenAiName()
+                                       )
+                               ).id
+                               
+                               this[name] = newId
+                               onNewFileId?.invoke(newId)
+                               
+                               logger.info("Factbook $name has been uploaded")
+                       }
+               }
+               
+               return robotGlobals.copy(lastFileUpload = Instant.now(), fileIdMap = fileIdMap).save()
+       }
+       
+       suspend fun initialize() {
+               var robotGlobals = updateFiles(RobotGlobals.get()?.gcOldThreads())
+               
+               val vectorStoreId = robotGlobals.vectorStoreId ?: robotClient.createVectorStore(
+                       RobotCreateVectorStoreRequest(
+                               name = "lore_documents",
+                               fileIds = robotGlobals.fileIdMap.values.toList(),
+                       )
+               ).id.also { vsId ->
+                       robotGlobals = robotGlobals.copy(vectorStoreId = vsId).save()
+               }
+               
+               logger.info("Vector store has been created")
+               
+               poll {
+                       robotClient.getVectorStore(vectorStoreId).status == "completed"
+               }
+               
+               logger.info("Vector store creation is complete")
+               
+               if (robotGlobals.assistantId == null)
+                       robotGlobals = robotGlobals.copy(
+                               assistantId = robotClient.createAssistant(
+                                       RobotCreateAssistantRequest(
+                                               model = "gpt-4-turbo",
+                                               name = "Natural-language Universal Knowledge Engine",
+                                               instructions = "You are a helpful interactive encyclopedia, able to answer questions with information from the provided files",
+                                               tools = listOf(
+                                                       RobotCreateAssistantRequestTool("file_search")
+                                               ),
+                                               toolResources = RobotCreateAssistantRequestToolResources(
+                                                       fileSearch = RobotCreateAssistantRequestFileSearchResources(
+                                                               vectorStoreIds = listOf(vectorStoreId)
+                                                       )
+                                               ),
+                                               temperature = 1.0
+                                       )
+                               ).id
+                       ).save()
+               
+               logger.info("Assistant has been created")
+       }
+       
+       suspend fun performMaintenance() {
+               var robotGlobals = RobotGlobals.get() ?: RobotGlobals()
+               
+               val vectorStoreId = robotGlobals.vectorStoreId ?: robotClient.createVectorStore(
+                       RobotCreateVectorStoreRequest(
+                               name = "lore_documents",
+                               fileIds = robotGlobals.fileIdMap.values.toList(),
+                       )
+               ).id.also { vsId ->
+                       robotGlobals = robotGlobals.copy(vectorStoreId = vsId).save()
+               }
+               
+               updateFiles(robotGlobals) { fileId ->
+                       robotClient.addFileToVectorStore(vectorStoreId, fileId)
+               }
+               
+               logger.info("Vector store has been updated")
+               
+               poll {
+                       robotClient.getVectorStore(vectorStoreId).fileCounts.inProgress == 0
+               }
+               
+               logger.info("Vector store update is complete")
+       }
+       
+       inner class Conversation(val nationId: Id<NationData>) {
+               private var assistantId: RobotAssistantId? = null
+               private var threadId: RobotThreadId? = null
+               
+               suspend fun send(userMessage: String): Flow<RobotConversationMessage> {
+                       val assistant = assistantId ?: pollValue { RobotGlobals.get()?.assistantId }
+                               .also { assistantId = it }
+                       
+                       val thread = threadId ?: createThread().also { threadId = it }
+                       
+                       val messages = listOf(
+                               RobotCreateThreadRequestMessage(
+                                       role = "user",
+                                       content = userMessage
+                               )
+                       )
+                       
+                       val tokenTracker = ConversationMessageTokenTracker()
+                       
+                       return flow {
+                               emit(RobotConversationMessage.User(userMessage))
+                               
+                               val annotationTargets = mutableListOf<Deferred<String>>()
+                               val collectionScope = CoroutineScope(currentCoroutineContext())
+                               
+                               robotClient.createRun(thread, assistant, messages)
+                                       .filter { it.event == "thread.message.delta" }
+                                       .mapNotNull { it.data }
+                                       .map { JsonRobotCodec.decodeFromString(RobotMessageDelta.serializer(), it) }
+                                       .collect { eventData ->
+                                               val annotationTexts = eventData.delta.content.flatMap { it.text.annotations }.map { annotation ->
+                                                       val annotationIndex = annotationTargets.size
+                                                       annotationTargets.add(collectionScope.async {
+                                                               val fileName = robotClient.getFile(annotation.fileCitation.fileId).filename.fromOpenAiName()
+                                                               val fileText = annotation.fileCitation.quote.let { if (it.isNotBlank()) ": $it" else it }
+                                                               "$MainDomainName/lore/$fileName$fileText"
+                                                       })
+                                                       annotation.text to " [${annotationIndex + 1}]"
+                                               }
+                                               
+                                               val contents = eventData.delta.content.joinToString(separator = "") { textContent ->
+                                                       textContent.text.value
+                                               }
+                                               
+                                               val replacedContents = annotationTexts.fold(contents) { text, (replace, replaceWith) ->
+                                                       text.replace(replace, replaceWith)
+                                               }
+                                               
+                                               emit(RobotConversationMessage.Robot(replacedContents))
+                                       }
+                               
+                               emit(RobotConversationMessage.Cite(annotationTargets.awaitAll()))
+                               
+                               emit(RobotConversationMessage.Ready)
+                       }.onEach { message ->
+                               tokenTracker.addMessage(message)
+                       }.onCompletion { _ ->
+                               RobotUser.addTokens(nationId, tokenTracker.calculateTokens())
+                       }
+               }
+               
+               suspend fun isExhausted(): Boolean {
+                       val usedTokens = RobotUser.getTokens(nationId)
+                       val tokenLimit = RobotUser.getMaxTokens(nationId)
+                       return usedTokens >= tokenLimit
+               }
+               
+               suspend fun close() {
+                       threadId?.let { deleteThread(it) }
+               }
+       }
+       
+       companion object {
+               private val logger: Logger = LoggerFactory.getLogger(RobotService::class.java)
+               
+               private val maintenanceScope = CoroutineScope(SupervisorJob() + CoroutineName("robot-service-maintenance"))
+               
+               private val instanceHolder by lazy {
+                       CoroutineScope(CoroutineName("robot-service-initialization")).async {
+                               Configuration.Current.openAi?.let { (token, orgId, project) ->
+                                       RobotService(token, orgId, project).apply {
+                                               initialize()
+                                       }
+                               }
+                       }
+               }
+               
+               var status: RobotServiceStatus = if (Configuration.Current.openAi != null) RobotServiceStatus.LOADING else RobotServiceStatus.NOT_CONFIGURED
+                       private set
+               
+               suspend fun getInstance() = try {
+                       instanceHolder.await()
+               } catch (ex: Exception) {
+                       null
+               }
+               
+               fun initialize() {
+                       instanceHolder.invokeOnCompletion { ex ->
+                               status = if (ex != null) {
+                                       logger.error("RobotService failed to initialize", ex)
+                                       RobotServiceStatus.FAILED
+                               } else {
+                                       logger.info("RobotService successfully initialized")
+                                       RobotServiceStatus.READY
+                               }
+                       }
+                       
+                       maintenanceScope.launch {
+                               getInstance()?.let { instance ->
+                                       while (true) {
+                                               delay(30.minutes)
+                                               
+                                               launch(SupervisorJob(currentCoroutineContext().job)) {
+                                                       instance.performMaintenance()
+                                               }
+                                       }
+                               }
+                       }
+               }
+       }
+}
+
+@Serializable
+sealed class RobotConversationMessage {
+       @Serializable
+       @SerialName("ready")
+       data object Ready : RobotConversationMessage()
+       
+       @Serializable
+       @SerialName("user")
+       data class User(val text: String) : RobotConversationMessage()
+       
+       @Serializable
+       @SerialName("robot")
+       data class Robot(val text: String) : RobotConversationMessage()
+       
+       @Serializable
+       @SerialName("cite")
+       data class Cite(val urls: List<String>) : RobotConversationMessage()
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/robot/RobotSse.kt b/src/jvmMain/kotlin/info/mechyrdia/robot/RobotSse.kt
new file mode 100644 (file)
index 0000000..e22df7a
--- /dev/null
@@ -0,0 +1,95 @@
+package info.mechyrdia.robot
+
+import io.ktor.client.*
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.utils.io.*
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.channels.ProducerScope
+import kotlinx.coroutines.channels.ReceiveChannel
+import kotlinx.coroutines.channels.produce
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.FlowCollector
+import kotlinx.coroutines.flow.flow
+
+data class ServerSentEvent(
+       val data: String?,
+       val event: String?,
+       val id: String?,
+       val retry: Double?,
+)
+
+private class SseBuilder {
+       var data: String? = null
+       var event: String? = null
+       var id: String? = null
+       var retry: Double? = null
+       
+       fun build() = ServerSentEvent(data, event, id, retry)
+       
+       val isSet: Boolean
+               get() = data != null || event != null || id != null || retry != null
+       
+       fun reset() {
+               data = ""
+               event = null
+               id = null
+               retry = null
+       }
+}
+
+private const val SSE_DATA_PREFIX = "data: "
+private const val SSE_EVENT_PREFIX = "event: "
+private const val SSE_ID_PREFIX = "id: "
+private const val SSE_RETRY_PREFIX = "retry: "
+
+private suspend fun FlowCollector<ServerSentEvent>.receiveSse(response: HttpResponse) {
+       val reader = response.bodyAsChannel()
+       val builder = SseBuilder()
+       while (true) {
+               val line = reader.readUTF8Line() ?: break
+               
+               if (line.isBlank()) {
+                       if (builder.isSet)
+                               emit(builder.build())
+                       builder.reset()
+                       continue
+               }
+               
+               if (line.startsWith(":")) continue
+               
+               if (line.startsWith(SSE_DATA_PREFIX))
+                       builder.data = builder.data?.let { "$it\n" }.orEmpty() + line.substring(SSE_DATA_PREFIX.length)
+               if (line.startsWith(SSE_EVENT_PREFIX))
+                       builder.event = line.substring(SSE_EVENT_PREFIX.length)
+               if (line.startsWith(SSE_ID_PREFIX))
+                       builder.id = line.substring(SSE_ID_PREFIX.length)
+               if (line.startsWith(SSE_RETRY_PREFIX))
+                       builder.retry = line.substring(SSE_RETRY_PREFIX.length).toDoubleOrNull()
+       }
+       
+       if (builder.isSet)
+               emit(builder.build())
+}
+
+suspend fun HttpClient.getSse(urlString: String, requestBuilder: suspend HttpRequestBuilder.() -> Unit): Flow<ServerSentEvent> {
+       return flow {
+               prepareGet(urlString) {
+                       requestBuilder()
+               }.execute { response ->
+                       receiveSse(response)
+               }
+       }
+}
+
+suspend fun HttpClient.postSse(urlString: String, requestBuilder: suspend HttpRequestBuilder.() -> Unit): Flow<ServerSentEvent> {
+       return flow {
+               preparePost(urlString) {
+                       requestBuilder()
+               }.execute { response ->
+                       receiveSse(response)
+               }
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/robot/RobotUserLimiter.kt b/src/jvmMain/kotlin/info/mechyrdia/robot/RobotUserLimiter.kt
new file mode 100644 (file)
index 0000000..a03f41b
--- /dev/null
@@ -0,0 +1,82 @@
+package info.mechyrdia.robot
+
+import com.mongodb.client.model.Filters
+import com.mongodb.client.model.Updates
+import info.mechyrdia.OwnerNationId
+import info.mechyrdia.data.*
+import info.mechyrdia.lore.MyTimeZone
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import java.time.Instant
+
+@Serializable
+data class RobotUser(
+       @SerialName(MONGODB_ID_KEY)
+       override val id: Id<RobotUser>,
+       
+       val usedByUser: Id<NationData>,
+       val usedInMonth: Int,
+       
+       val tokensUsed: Int,
+) : DataDocument<RobotUser> {
+       companion object : TableHolder<RobotUser> {
+               override val Table: DocumentTable<RobotUser> = DocumentTable<RobotUser>()
+               
+               override suspend fun initialize() {
+                       Table.unique()
+               }
+               
+               private fun currentMonth(): Int {
+                       val now = Instant.now().atZone(MyTimeZone)
+                       return (now.year - 2024) * 12 + now.month.ordinal
+               }
+               
+               fun getMaxTokens(nationId: Id<NationData>): Int = if (nationId == OwnerNationId)
+                       100_000_000
+               else 100_000
+               
+               suspend fun getTokens(nationId: Id<NationData>): Int {
+                       return Table.locate(
+                               Filters.and(
+                                       Filters.eq(RobotUser::usedByUser.serialName, nationId),
+                                       Filters.eq(RobotUser::usedInMonth.serialName, currentMonth()),
+                               )
+                       )?.tokensUsed ?: 0
+               }
+               
+               suspend fun addTokens(nationId: Id<NationData>, tokens: Int) {
+                       Table.change(
+                               Filters.and(
+                                       Filters.eq(RobotUser::usedByUser.serialName, nationId),
+                                       Filters.eq(RobotUser::usedInMonth.serialName, currentMonth()),
+                               ),
+                               Updates.combine(
+                                       Updates.inc(RobotUser::tokensUsed.serialName, tokens),
+                                       Updates.setOnInsert(RobotUser::id.serialName, Id<RobotUser>()),
+                               )
+                       )
+               }
+       }
+}
+
+private const val REQUEST_TOKEN_WEIGHT = 1
+private const val RESPONSE_TOKEN_WEIGHT = 3
+
+class ConversationMessageTokenTracker {
+       private val request = StringBuffer()
+       private val response = StringBuffer()
+       
+       fun addMessage(message: RobotConversationMessage) {
+               when (message) {
+                       is RobotConversationMessage.User -> request.append(message.text)
+                       is RobotConversationMessage.Robot -> response.append(message.text)
+                       else -> {
+                               // ignore
+                       }
+               }
+       }
+       
+       suspend fun calculateTokens(): Int {
+               return (request.toString().countTokens() * REQUEST_TOKEN_WEIGHT) + (response.toString().countTokens() * RESPONSE_TOKEN_WEIGHT)
+       }
+}
diff --git a/src/jvmMain/kotlin/info/mechyrdia/robot/ViewsRobot.kt b/src/jvmMain/kotlin/info/mechyrdia/robot/ViewsRobot.kt
new file mode 100644 (file)
index 0000000..4dffd6a
--- /dev/null
@@ -0,0 +1,86 @@
+package info.mechyrdia.robot
+
+import info.mechyrdia.data.currentNation
+import info.mechyrdia.lore.page
+import info.mechyrdia.lore.redirectHref
+import info.mechyrdia.lore.standardNavBar
+import info.mechyrdia.route.Root
+import info.mechyrdia.route.checkCsrfToken
+import info.mechyrdia.route.createCsrfToken
+import info.mechyrdia.route.href
+import io.ktor.server.application.*
+import io.ktor.server.websocket.*
+import io.ktor.websocket.*
+import io.ktor.websocket.CloseReason.*
+import kotlinx.html.*
+import kotlinx.serialization.json.JsonPrimitive
+
+suspend fun ApplicationCall.robotPage(): HTML.() -> Unit {
+       val nation = currentNation()?.id ?: redirectHref(Root.Auth.LoginPage(Root.Auth(Root(error = "You must be logged in to use the NUKE"))))
+       val exhausted = RobotUser.getTokens(nation) >= RobotUser.getMaxTokens(nation)
+       
+       val nukeRoute = href(Root.Nuke.WS())
+       val token = createCsrfToken(nukeRoute)
+       
+       val robotServiceStatus = RobotService.status
+       
+       return page("NUKE", standardNavBar(), null) {
+               section {
+                       h1 { +"NUKE" }
+                       p {
+                               +"The "
+                               b { +"NUKE" }
+                               +" (Natural-language Universal Knowledge Engine) is an interactive encyclopedia that answers questions about the galaxy."
+                       }
+                       if (exhausted)
+                               p { +"You have exhausted your monthly limit of NUKE usage." }
+                       else
+                               when (robotServiceStatus) {
+                                       RobotServiceStatus.NOT_CONFIGURED -> p { +"Unfortunately, the NUKE is not configured on this website." }
+                                       RobotServiceStatus.LOADING -> p { +"The NUKE is still in the process of initializing." }
+                                       RobotServiceStatus.FAILED -> p { +"Tragically, the NUKE has failed to initialize due to an internal error." }
+                                       RobotServiceStatus.READY -> script {
+                                               unsafe {
+                                                       val jsToken = JsonPrimitive(token).toString()
+                                                       +"window.createNukeBox($jsToken);"
+                                               }
+                                       }
+                               }
+               }
+       }
+}
+
+suspend fun WebSocketSession.closeReasonably(reason: String) = close(CloseReason(Codes.NORMAL, reason))
+
+suspend fun DefaultWebSocketServerSession.robotConversation(csrfToken: String? = null) {
+       val nation = call.currentNation()?.id ?: return closeReasonably("Anonymous usage of NUKE is not allowed")
+       if (!call.checkCsrfToken(csrfToken, call.href(Root.Nuke.WS())))
+               return closeReasonably("CSRF token failed verification")
+       
+       val robotService = RobotService.getInstance() ?: return closeReasonably("NUKE is not configured on this website")
+       
+       val conversation = robotService.Conversation(nation)
+       
+       if (conversation.isExhausted()) {
+               conversation.close()
+               return closeReasonably("You have exhausted your monthly limit of NUKE usage")
+       }
+       
+       sendSerialized<RobotConversationMessage>(RobotConversationMessage.Ready)
+       
+       for (frame in incoming) {
+               if (frame !is Frame.Text) continue
+               val query = frame.readText()
+               
+               conversation.send(query).collect { message ->
+                       sendSerialized(message)
+               }
+               
+               if (conversation.isExhausted()) {
+                       conversation.close()
+                       return closeReasonably("You have exhausted your monthly limit of NUKE usage")
+               }
+       }
+       
+       conversation.close()
+}
index 0d17cfec54b97d950282cd5f334c2224f9ec2560..80e9809b62f0c6e5e572a086aa6ab275f5e40b06 100644 (file)
@@ -35,12 +35,12 @@ fun ApplicationCall.csrfPayload(route: String, withExpiration: Instant = Instant
 
 private val csrfMap = ConcurrentHashMap<String, CsrfPayload>()
 
-data class CsrfFailedException(override val message: String, val payload: CsrfProtectedResourcePayload) : RuntimeException(message)
+data class CsrfFailedException(override val message: String, val payload: CsrfProtectedResourcePayload?) : RuntimeException(message)
 
 interface CsrfProtectedResourcePayload {
        val csrfToken: String?
        
-       suspend fun ApplicationCall.verifyCsrfToken(route: String = request.uri) {
+       fun ApplicationCall.verifyCsrfToken(route: String = request.uri) {
                val token = csrfToken ?: throw CsrfFailedException("The submitted CSRF token is not present", this@CsrfProtectedResourcePayload)
                val check = csrfMap.remove(token) ?: throw CsrfFailedException("The submitted CSRF token is not valid", this@CsrfProtectedResourcePayload)
                val payload = csrfPayload(route, check.expires)
@@ -53,6 +53,13 @@ interface CsrfProtectedResourcePayload {
        fun FlowContent.displayRetryData() {}
 }
 
+fun ApplicationCall.checkCsrfToken(csrfToken: String?, route: String = request.uri): Boolean {
+       val token = csrfToken ?: return false
+       val check = csrfMap.remove(token) ?: return false
+       val payload = csrfPayload(route, check.expires)
+       return check == payload && payload.expires >= Instant.now()
+}
+
 fun ApplicationCall.createCsrfToken(route: String = request.origin.uri): String {
        return token().also { csrfMap[it] = csrfPayload(route) }
 }
index 0d95e89b6bc3ebb2482f7c08ef04000b53c8b565..330ba20e148a5c6247e1efe3392bd4ba75cf44a0 100644 (file)
@@ -3,14 +3,16 @@ package info.mechyrdia.route
 import io.ktor.http.*
 import io.ktor.resources.serialization.*
 import io.ktor.server.application.*
+import io.ktor.server.plugins.*
 import io.ktor.server.request.*
 import io.ktor.server.resources.*
-import io.ktor.server.routing.Route
+import io.ktor.server.resources.post
+import io.ktor.server.routing.*
+import io.ktor.server.websocket.*
+import io.ktor.util.*
 import io.ktor.util.pipeline.*
-import kotlinx.serialization.DeserializationStrategy
-import kotlinx.serialization.KSerializer
-import kotlinx.serialization.SerializationStrategy
-import kotlinx.serialization.StringFormat
+import kotlinx.html.P
+import kotlinx.serialization.*
 import kotlinx.serialization.builtins.nullable
 import kotlinx.serialization.builtins.serializer
 import kotlinx.serialization.descriptors.PrimitiveKind
@@ -25,6 +27,10 @@ interface ResourceHandler {
        suspend fun PipelineContext<Unit, ApplicationCall>.handleCall()
 }
 
+interface ResourceListener {
+       suspend fun DefaultWebSocketServerSession.handleCall()
+}
+
 interface ResourceReceiver<P : Any> {
        suspend fun PipelineContext<Unit, ApplicationCall>.handleCall(payload: P)
 }
@@ -53,6 +59,28 @@ inline fun <reified T : ResourceReceiver<P>, reified P : MultiPartPayload> Route
        }
 }
 
+val WebSocketResourceInstanceKey: AttributeKey<Any> = AttributeKey("WebSocketResourceInstance")
+
+inline fun <reified T : ResourceListener> Route.ws() {
+       resource<T> {
+               val serializer = serializer<T>()
+               intercept(ApplicationCallPipeline.Plugins) {
+                       val resources = application.plugin(Resources)
+                       try {
+                               val resource = resources.resourcesFormat.decodeFromParameters(serializer, call.parameters)
+                               call.attributes.put(WebSocketResourceInstanceKey, resource)
+                       } catch (cause: Throwable) {
+                               throw BadRequestException("Can't transform call to resource", cause)
+                       }
+               }
+               
+               webSocket {
+                       val resource = call.attributes[WebSocketResourceInstanceKey] as T
+                       with(resource) { handleCall() }
+               }
+       }
+}
+
 abstract class KeyedEnumSerializer<E : Enum<E>>(val entries: EnumEntries<E>, val getKey: (E) -> String? = { it.name }) : KSerializer<E> {
        override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("KeyedEnumSerializer<${entries.first()::class.qualifiedName}>", PrimitiveKind.STRING)
        
@@ -87,4 +115,5 @@ class FormUrlEncodedFormat(private val resourcesFormat: ResourcesFormat) : Strin
 
 inline fun <reified T : Any> Application.href(resource: T, hash: String? = null): String = URLBuilder().also { href(resource, it) }.build().fullPath + hash?.let { "#$it" }.orEmpty()
 inline fun <reified T : Any> ApplicationCall.href(resource: T, hash: String? = null) = application.href(resource, hash)
+inline fun <reified T : Any> WebSocketServerSession.href(resource: T, hash: String? = null) = application.href(resource, hash)
 inline fun <reified T : Any> PipelineContext<Unit, ApplicationCall>.href(resource: T, hash: String? = null) = application.href(resource, hash)
index bb2c26eb619ad41e4faee0d3cf1ffaf33d710be5..1b30ea216c39c601d269c0c3da1ae2de32e1d38a 100644 (file)
@@ -3,6 +3,8 @@ package info.mechyrdia.route
 import info.mechyrdia.auth.*
 import info.mechyrdia.data.*
 import info.mechyrdia.lore.*
+import info.mechyrdia.robot.robotConversation
+import info.mechyrdia.robot.robotPage
 import io.ktor.http.*
 import io.ktor.http.content.*
 import io.ktor.resources.*
@@ -10,6 +12,7 @@ import io.ktor.server.application.*
 import io.ktor.server.html.*
 import io.ktor.server.plugins.*
 import io.ktor.server.response.*
+import io.ktor.server.websocket.*
 import io.ktor.util.*
 import io.ktor.util.pipeline.*
 import kotlinx.coroutines.delay
@@ -147,6 +150,22 @@ class Root(val error: String? = null) : ResourceHandler, ResourceFilter {
                }
        }
        
+       @Resource("nuke")
+       class Nuke(val root: Root = Root()) : ResourceHandler {
+               override suspend fun PipelineContext<Unit, ApplicationCall>.handleCall() {
+                       with(root) { filterCall() }
+                       
+                       call.respondHtml(HttpStatusCode.OK, call.robotPage())
+               }
+               
+               @Resource("ws")
+               class WS(val csrfToken: String? = null, val nuke: Nuke = Nuke()) : ResourceListener {
+                       override suspend fun DefaultWebSocketServerSession.handleCall() {
+                               robotConversation(csrfToken)
+                       }
+               }
+       }
+       
        @Resource("comment")
        class Comments(val root: Root = Root()) : ResourceFilter {
                override suspend fun PipelineContext<Unit, ApplicationCall>.filterCall() {
index bf39bae968b31e8640a0ec3901af65f207233e3e..23fd68ff507f59003ddd6f50f8a6dd198d771b12 100644 (file)
                });
        }
 
+       function appendWithLineBreaks(element, text) {
+               const lines = text.split("\n");
+               let isFirst = true;
+               for (const line of lines) {
+                       if (isFirst)
+                               isFirst = false;
+                       else
+                               element.append(document.createElement("br"));
+                       element.append(line);
+               }
+       }
+
        window.addEventListener("load", function () {
                // Mechyrdian font
                async function mechyrdianToFont(input, boldOpt, italicOpt, alignOpt, output, delayLength) {
 
                window.localStorage.removeItem("redirectedFrom");
        };
+
+       window.createNukeBox = function (csrfToken) {
+               const chatHistory = document.createElement("blockquote");
+               chatHistory.style.overflowY = "scroll";
+               chatHistory.style.height = "40vh";
+
+               const inputBox = document.createElement("input");
+               inputBox.classList.add("inline");
+               inputBox.style.flexGrow = "1";
+               inputBox.type = "text";
+               inputBox.placeholder = "Enter your message";
+
+               const enterBtn = document.createElement("input");
+               enterBtn.classList.add("inline");
+               enterBtn.style.flexShrink = "0";
+               enterBtn.type = "submit";
+               enterBtn.value = "Send";
+               enterBtn.disabled = true;
+
+               const inputForm = document.createElement("form");
+               inputForm.style.display = "flex";
+               inputForm.append(inputBox, enterBtn);
+
+               const container = document.createElement("div");
+               container.append(chatHistory, inputForm);
+               document.currentScript.after(container);
+
+               const targetUrl = "ws" + window.location.href.substring(4) + "/ws?csrfToken=" + csrfToken;
+               const webSock = new WebSocket(targetUrl);
+
+               inputForm.onsubmit = (ev) => {
+                       ev.preventDefault();
+                       if (!ev.submitter.disabled) {
+                               webSock.send(inputBox.value);
+                               inputBox.value = "";
+                               enterBtn.disabled = true;
+                       }
+               };
+
+               webSock.onmessage = (ev) => {
+                       const data = JSON.parse(ev.data);
+                       if (data.type === "ready") {
+                               enterBtn.disabled = false;
+                       } else if (data.type === "user") {
+                               const userP = document.createElement("p");
+                               userP.style.textAlign = "right";
+                               userP.style.paddingLeft = "50%";
+                               appendWithLineBreaks(userP, data.text);
+                               chatHistory.appendChild(userP);
+
+                               const robotP = document.createElement("p");
+                               robotP.style.textAlign = "left";
+                               robotP.style.paddingRight = "50%";
+                               chatHistory.appendChild(robotP);
+                       } else if (data.type === "robot") {
+                               const robotP = chatHistory.lastElementChild;
+                               appendWithLineBreaks(robotP, data.text);
+                       } else if (data.type === "cite") {
+                               const robotP = chatHistory.lastElementChild;
+                               const robotCiteList = robotP.appendChild(document.createElement("ol"));
+                               for (const url of data.urls) {
+                                       const urlLink = robotCiteList.appendChild(document.createElement("li")).appendChild(document.createElement("a"));
+                                       urlLink.href = url;
+                                       urlLink.append(url);
+                               }
+                       }
+               };
+
+               webSock.onclose = (ev) => {
+                       const statusP = document.createElement("p");
+                       statusP.style.textAlign = "center";
+                       statusP.style.paddingLeft = "25%";
+                       statusP.style.paddingRight = "25%";
+                       appendWithLineBreaks(statusP, "The connection has been closed\n" + ev.reason);
+                       chatHistory.appendChild(statusP);
+
+                       enterBtn.disabled = true;
+               };
+       };
 })();
index d92e211b32bb2666cf6b96517825fc621ef581c4..10458f9a1f42b4f36b7a8412c14de802eac0f7f8 100644 (file)
@@ -568,6 +568,12 @@ th {
        color: var(--tbl-td-bgr);
 }
 
+input[type=text].inline,
+input[type=password].inline,
+input[type=email].inline {
+       width: unset;
+}
+
 input[type=text],
 input[type=password],
 input[type=email],
@@ -605,6 +611,14 @@ textarea:invalid {
        border-bottom-color: var(--err-ul);
 }
 
+button.inline, input[type=submit].inline {
+       display: inline;
+       font-size: 1em;
+       margin: 0.25em;
+       padding: 0.45em 0.65em;
+       width: unset;
+}
+
 button, input[type=submit] {
        background-color: var(--btn-bg);
        border: none;