chat.kt
src/jvmMain/kotlin/afield/app/server/chat.kt · 164 lines
package and imports — 53 lines, from line 1
package afield.app.server
import afield.ai.agent.*
import afield.ai.conversation.*
import afield.ai.tools.*
import afield.animation.gesture.*
import afield.animation.interpolation.*
import afield.animation.keyframes.*
import afield.app.diagnostics.*
import afield.app.notifications.*
import afield.app.protocol.*
import afield.app.server.*
import afield.app.settings.*
import afield.audio.synth.*
import afield.data.table.*
import afield.document.context.*
import afield.document.graph.*
import afield.document.history.*
import afield.document.persistence.*
import afield.document.properties.*
import afield.document.workspace.*
import afield.graphics.color.*
import afield.graphics.paint.*
import afield.graphics.vulkan.*
import afield.math.functions.*
import afield.media.capture.*
import afield.media.stream.*
import afield.media.video.*
import afield.runtime.completion.*
import afield.runtime.execution.*
import afield.runtime.kotlin.*
import afield.runtime.lifecycle.*
import afield.tools.automation.*
import afield.tools.docs.*
import afield.ui.controls.*
import afield.ui.layout.*
import afield.ui.notation.*
import afield.ui.output.*
import afield.ui.output.templates.*
import afield.ui.slides.*
import afield.ui.visualization.*
import afield.app.diagnostics.logging.lprintln
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromJsonElement
The server side of the Discussion thread that a slideshow step or a box can show: two RPC handlers that carry messages between Field's clients and an outside chat service.
A thread is addressed by an id and a number, and the pair is a name rather than a location:
withChat(id) on a step can hand it a template id, so that every document made from the same
template shares one thread, and Box.withChat passes the box's own id. load-chat-messages
fetches a thread and replies only to the client that asked for it; post-chat-message sends one
and then broadcasts chat-message-posted to every client of the session, so any open widget
reloads the thread. Field's browser never calls the service itself.
Field ships no such service. The Python one was removed from the repository in October 2026, so
unless something compatible is listening on CHAT_SERVER_URL the Discussion UI appears, loads an
empty thread and drops what is posted into it. The rest of a slideshow is unaffected by that: the
requests run outside slideshow execution, a failed load is answered as an empty list, and a
failed post is logged and not answered at all. dev_docs/GUIDE_CHAT_QUICKSTART.md lists what a
replacement backend would have to serve.
fun chat()
Install Field's guide-chat RPC bridge.
load-chat-messages and post-chat-message proxy to the Flask service selected by
CHAT_SERVER_URL (default http://localhost:5002). Loads reply only to their requester; a
successful post broadcasts chat-message-posted through Field so visible widgets reload the
thread. Field's browser does not connect directly to the Flask Socket.IO endpoint.
Requests run in GlobalScope and the client is process-lived. A load failure is represented as
an empty list; a post failure is logged and has no success/error reply. The service is therefore
optional and intentionally outside slideshow execution.
body — 78 lines, from line 86
@RunsAt(installHandlers)
@OptIn(DelicateCoroutinesApi::class)
fun chat() {
// Chat server configuration
val chatServerUrl = System.getenv("CHAT_SERVER_URL") ?: "http://localhost:5002"
val chatHttpClient = HttpClient(CIO) {
install(io.ktor.client.plugins.contentnegotiation.ContentNegotiation) {
json()
}
}
handlers["load-chat-messages"] = mutableSetOf({ message, reply ->
val request = Json.decodeFromJsonElement<LoadChatMessages>(message.payload!!)
lprintln("debug,chat", "Loading chat messages for ${request.slideshowId}/${request.slideNumber}")
GlobalScope.launch {
try {
val response: HttpResponse = chatHttpClient.get("$chatServerUrl/chat/${request.slideshowId}/${request.slideNumber}")
val messagesJson = response.bodyAsText()
val messages = Json.decodeFromString<List<ChatMessage>>(messagesJson)
reply(Message("chat-messages-loaded").payload(
ChatMessagesLoaded(
slideshowId = request.slideshowId,
slideNumber = request.slideNumber,
messages = messages
)
))
} catch (e: Exception) {
lprintln("error,chat", "Error loading chat messages: ${e.message}")
// Send empty list on error
reply(Message("chat-messages-loaded").payload(
ChatMessagesLoaded(
slideshowId = request.slideshowId,
slideNumber = request.slideNumber,
messages = emptyList()
)
))
}
}
Handled.yes
})
handlers["post-chat-message"] = mutableSetOf({ message, reply ->
val request = Json.decodeFromJsonElement<PostChatMessage>(message.payload!!)
lprintln("debug,chat", "Posting chat message: ${request.username}: ${request.message}")
GlobalScope.launch {
try {
val response: HttpResponse = chatHttpClient.post("$chatServerUrl/chat/${request.slideshowId}/${request.slideNumber}") {
contentType(ContentType.Application.Json)
setBody(mapOf(
"username" to request.username,
"message" to request.message
))
}
val messageJson = response.bodyAsText()
val postedMessage = Json.decodeFromString<ChatMessage>(messageJson)
// Broadcast to all connected clients in this session
_send(Message("chat-message-posted").payload(
ChatMessagePosted(
slideshowId = request.slideshowId,
slideNumber = request.slideNumber,
message = postedMessage
)
))
} catch (e: Exception) {
lprintln("error,chat", "Error posting chat message: ${e.message}")
e.printStackTrace()
}
}
Handled.yes
})
}