Die Web-Chat-UI

Ein vollständiger Browser-Chat-Client - Seitenleiste, Streaming, Genehmigungen, SQLite-Store.

On this page

Die Web-Chat-UI

Ein gateways/*.bx-Eintrag mit exposes: "webui" liefert einen vollständigen Browser-Chat-Client für den Agenten aus - eine Konversations-Seitenleiste, Streaming mit Reasoning und Tool-Aufrufen, Human-in-the-Loop-Genehmigungen, Theming pro Besucher, und einen echten SQLite-Store dahinter.

Sie lebt unter gateways/, weil dort Exposures deklariert werden, ist aber ein eigenständiges Subsystem, weshalb sie eine eigene Seite hat.

// gateways/chat.bx
class {
	function configure() {
		return {
			exposes     : "webui",
			path        : "/chat",
			apiKeyEnvVar: "CHAT_UI_API_KEY"   // optional - see Securing the API
		};
	}
}

Das erzeugt eine statische <path>/index.html (direkt bedient - keine Route nötig) plus eine dedizierte API unter <path>/api, unterlegt von einem generierten handlers/ChatUi.bx und models/ChatDb.bx.

Die UI ist abhängigkeitsfreies, reines HTML/CSS/JS - kein Bootstrap, kein AlpineJS, kein Vite-Build-Schritt - und ist vorgebaut und in BX Agents selbst vendort: bxAgents build führt nie npm install/npm run build aus, und ein generiertes Projekt braucht überhaupt nie Node oder npm installiert. Alles, was die Seite braucht, ist in das einzelne generierte index.html eingebettet.

Diese Einschränkung betrifft den Build, nicht den Funktionsumfang. Die Seite ist ein vollständiger Client: Konversations-Seitenleiste, Streaming mit Reasoning und Tool-Aufrufen, Genehmigungen, Kompaktierung, serverseitiges Theming. Was tatsächlich noch fehlt, steht unter Was hier noch fehlt.

Die Seite spricht mit der eigenen generierten <path>/api-Route über POST <path>/api/stream (Accept: text/event-stream), mittels fetch() + einem manuellen ReadableStream-Reader - nicht dem EventSource des Browsers, das weder POST kann noch eigene Header setzen kann, beides hier nötig.

Warning

toAi() reicht jeden bx-ai-Chunk unverändert durch - es wickelt ihn nicht ein. ColdBoxs AI-Routing-Dokumentation zeigt den Stream als data: {"token":"..."}-Zeilen, aber ihr eigener Quellcode (Router.cfc, toAi()s Stream-Unterroute) macht emitter.send( chunk, "chunk" ) - jeder Frame trägt also die vollständige, normalisierte bx-ai-Hülle:

event: chunk
data: {"object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant","content":"Ray","reasoning":"...","tool_calls":[...]}}]}

event: done
data: [DONE]

Es gibt nirgendwo einen token-SchlĂĽssel. Ein gegen diese Doku-Seite geschriebener Client - einschlieĂźlich der eigenen ersten Version dieser UI - liest undefined und rendert ĂĽberhaupt nichts. Stattdessen choices[0].delta.content lesen.

Weil die gesamte Hülle ankommt, liegen Reasoning und Tool-Aufrufe bereits auf dem Draht, ohne dass ein zusätzlicher Endpunkt nötig wäre: delta.reasoning (von bx-ai über jeden Provider hinweg normalisiert) rendert als eingeklappter "Denkt nach"-Streifen, und delta.tool_calls als eingeklappte Chips pro Aufruf. Tool-Aufruf-Argumente streamen als partielle JSON-Fragmente, geschlüsselt nach index, die Seite akkumuliert also pro Index, statt anzunehmen, dass ein einzelner Chunk je einen vollständigen Aufruf enthält.

Wie ein Streaming-Turn tatsächlich aussieht

sequenceDiagram
    autonumber
    participant B as browser (generated index.html)
    participant H as handlers/ChatUi.bx
    participant A as the agent
    participant D as models/ChatDb.bx (SQLite)

    B->>H: POST /chat/api/stream, Accept: text/event-stream
    H->>D: resolve the conversation for this session
    H->>A: agent.stream( ... )
    H-->>B: event: thread - the threadId, sent BEFORE the first chunk
    loop for every chunk bx-ai emits
        A-->>H: a full normalized bx-ai envelope
        H-->>B: event: chunk - choices[0].delta.content / .reasoning / .tool_calls
    end
    H->>D: persist the turn
    H-->>B: event: done - [DONE]

Das thread-Event geht zuerst, weil ein Response-Header nicht gelesen werden kann, bevor der Body zu kommen beginnt, und die Seite die threadId braucht, um mitten im Turn POST /cancel aufrufen zu können.

Die generierte API

Ein webui-Eintrag mountet zwanzig Actions unter <path>/api, bedient von einem generierten handlers/ChatUi.bx:

RouteZweck
POST /invokeEin synchroner Turn
POST /streamSSE-Turn (was die Seite nutzt)
POST /batchEin inputs[]-Array ausfĂĽhren
POST /cancelEinen laufenden Run stoppen - { threadId, reason? }
POST /steerEine Nachricht in einen laufenden Turn einfĂĽgen - { threadId, input }
POST /clearDie Konversation dieses Besuchers leeren
POST /compactÄltere Nachrichten dieses Besuchers zusammenfassen, die aktuellen behalten - optional { keepRecent }
GET /historyDie gespeicherten Nachrichten dieses Besuchers, um das Transkript zu rehydrieren
POST /resumeEine ausstehende Genehmigung beantworten und die Fortsetzung streamen - { threadId, decision, editedData?, reason? }
GET /pendingWorauf ein suspendierter Run wartet - ?threadId=
GET /toolsDie registrierten Tools des Agenten
GET /healthLiveness
GET /infoAgentenname, Modell, Memory-/Tool-Zähler, Capability-Flags
GET /conversationsDie Konversationen dieses Besuchers, neueste Aktivität zuerst
POST /conversations/createEine starten - optional { title }, liefert die geprägte conversationId
POST /conversations/rename{ conversationId, title }
POST /conversations/delete{ conversationId } - löscht die Zeile und die Nachrichten des Agenten dafür
GET /preferencesDie gespeicherten Präferenzen dieses Besuchers, als { key: value }
POST /preferences/set{ key, value }
POST /preferences/delete{ key }

Jede ist ĂĽber ColdBoxs getUserSessionIdentifier() als userId gescoped. Die ersten drei behalten toAi()s exakte Form und Drahtformat.

threadId ist serverautoritativ: aus dem Request übernommen, wenn mitgeliefert, sonst geprägt, und immer zurückgemeldet - als X-Thread-Id-Response-Header bei /invoke und /batch, und als thread-SSE-Event, gesendet vor dem ersten Chunk bei /stream (ein Header kann nicht gelesen werden, bevor der Body zu kommen beginnt). Das ist derselbe Vertrag, den ColdBox 8.1s eigenes toAi() angenommen hat, ein gegen das eine geschriebener Client funktioniert also auch gegen das andere.

Warning

Stop muss über /cancel laufen, nicht nur über ein abgebrochenes Fetch. Das HTTP-Request abzubrechen stoppt nur den Browser beim Zuhören - der Server läuft mit dem Turn weiter, ruft Tools auf und verbraucht Tokens. Die Seite sendet daher bei jedem Turn eine threadId mit und postet sie an /cancel, bevor sie abbricht, damit agent.cancelRun() den Run an seinem nächsten Checkpoint signalisieren kann.

/clear und /compact sind beide vorsichtig mit dem Scope. /clear geht über das eigene clear( userId, conversationId ) jedes Memory statt über AiAgent.clearMemory(), das keine Argumente nimmt und die Historie jedes Besuchers löschen würde; /compact geht aus demselben Grund über summarize( config, userId, conversationId ). Kompaktierung ersetzt die älteren Nachrichten dieser Konversation durch eine KI-verfasste Zusammenfassung und behält die letzten paar, ohne irgendetwas außerhalb des (userId, conversationId)-Paars des Aufrufers anzufassen.

Info

/compact braucht ein Zusammenfassungsmodell und meldet, ob es eines hat. summarize() ist ein stiller No-Op, sofern das Memory nicht sowohl summaryProvider als auch summaryModel konfiguriert hat, und auch dann, wenn die Konversation bereits bei oder unter keepRecent liegt. Keines von beiden ist ein Fehler, /compact gibt also { compacted, before, after } zurück und lässt die Aufruferin selbst sehen, und /infos capabilities.compact meldet, ob überhaupt ein Zusammenfassungsmodell konfiguriert ist - sodass eine Seite eine Schaltfläche verstecken kann, die nichts tun würde, statt kaputt auszusehen.

Nur keepRecent wird aus dem Request ĂĽbernommen. summarize() respektiert auch model-/provider-Overrides, aber diese hier zu akzeptieren wĂĽrde es jedem Besucher erlauben, einen Zusammenfassungsaufruf auf einen Provider und ein Modell eigener Wahl auf den eigenen Zugangsdaten zu richten - das entscheidet stattdessen die eigene Konfiguration des Memory.

// Agent.bx - what makes /compact functional
memory: {
	type            : "cache",
	summaryProvider : "openai",
	summaryModel    : "gpt-4o-mini",
	summaryThreshold: 10
}

Benutzer und Anmeldung

Standardmäßig hat die Web-UI keine Konten und kein Gate — sie ist offen, und jeder Besucher ist anonym. Das ist das aufwandsfreie bxAgents serve-Erlebnis, und es ist keine Deployment-Haltung. users an einem webui-Eintrag zu deklarieren schaltet ein echtes Anmelde-Gate ein, gestützt auf cbauth und denselben SQLite-Store, den alles andere nutzt.

Ohne Konten ist die UI ein gemeinsamer Arbeitsbereich

Es gibt bewusst keine Pro-Besucher-Identität. Jeder Besucher einer Web-UI ohne Konten liest und schreibt dieselben Konversationen, Präferenzen und das Agentengedächtnis — wer auch immer die Seite erreichen kann, sieht alles darin.

Das ist der Sinn, ohne Konten zu laufen, kein Versehen: Eine offene UI ist ein einzelnes gemeinsames Werkzeug (ein Laptop, eine vertrauenswürdige interne Maschine), kein Multi-Tenant-Dienst. Jedem Browser seine eigene Scheibe zu geben würde nur einen Arbeitsbereich in Pro-Browser-Kopien zersplittern, um die niemand gebeten hat, und jede clientseitige ID, die diese Zersplitterung vornähme, wäre ohnehin fälschbar.

Warning

Eine offene UI hat keine Privatsphäre zwischen Besuchern. Wer auch immer die URL erreichen kann, sieht jede Konversation darin und kann jede davon fortsetzen oder löschen. Ist das nicht gewünscht — überall, wo die Seite von mehr Personen erreicht werden kann als denen, die die Transkripte sehen sollen — users deklarieren.

// gateways/chatUi.bx
users : [
    { username: "ada",   passwordEnvVar: "ACME_ADA_PASSWORD", displayName: "Ada Lovelace" },
    { username: "grace", passwordHash: "pbkdf2$210000$...",   displayName: "Grace Hopper" }
]

Passwörter werden nie in der Konfiguration geschrieben

Ein Konto benennt die Umgebungsvariable, die sein Passwort enthält (passwordEnvVar), oder trägt einen bereits gehashten Wert (passwordHash). Ein literaler password-Schlüssel ist ein Build-Fehler, keine Warnung — ihn still zu ignorieren würde den Eindruck hinterlassen, ein Passwort gesetzt zu haben, obwohl nur eines committet wurde.

Ein passwordHash ist bedenkenlos committebar, gerade weil er nicht umkehrbar ist. Einen erzeugen mit demselben Hasher, den die App nutzt:

bxAgents hash-password --password="correct horse battery staple"
Danger

Gehasht, nicht verschlüsselt. Verschlüsselung ist umkehrbar, und eine gestohlene Datenbankdatei reist fast immer mit allem, was sie entschlüsseln könnte — ein umkehrbares Schema verwandelt also ein einziges Datei-Leck in das Passwort jedes Nutzers, einschließlich jedem, den sie anderswo wiederverwendet haben. Passwörter durchlaufen hier PBKDF2-HMAC-SHA256 mit einem zufälligen Salt pro Nutzer und sind nie aus der Datenbank wiederherstellbar. (BoxLang liefert keinen bcrypt- oder argon2-BIF; PBKDF2 ist die stärkste verfügbare Primitive, ohne eine Abhängigkeit hinzuzufügen.)

Die Iterationszahl ist innerhalb jedes Hashes gespeichert (pbkdf2$<iterations>$<salt>$<digest>), sie kann also später angehoben werden, ohne etwas bereits Gespeichertes zu invalidieren.

Was sich mit der Anmeldung ändert

Alles Nutzer-gescopte schlüsselt sich neu auf das echte Konto um. Der generierte handlers/ChatUi.bx löst die Identität direkt aus cbauth auf, in einer Methode (resolveUserId()), und das Agentengedächtnis, der Konversationsindex, Präferenzen und die Eigentümerschaft laufender Runs schlüsseln sich alle auf ihren Rückgabewert.

Es liest bewusst cbauth statt ColdBoxs identifierProvider-Einstellung: Eine im coldbox-Konfigurationsstruct deklarierte Closure erreicht configSettings nie — in einem echten Boot verifiziert, sowohl in der dokumentierten literalen Form als auch als spätere Zuweisung — irgendetwas, das sich auf diese Einstellung stützte, erhielt also still eine Session-ID statt der echten Identität.

Der praktische Unterschied: Konversationen und Präferenzen folgen der Person über Browser und Geräte hinweg, und Cookies löschen erzeugt keinen brandneuen "Nutzer" mehr.

Ohne usersMit users
IdentitätEin gemeinsamer ArbeitsbereichDas angemeldete Konto
Konversationen sichtbar fĂĽrJeden, der die UI erreichen kannNur den eigenen Besitzer
Folgt der Person über Browser/Geräten/a — nichts ist personenbezogenJa
Erreichbar ohne AnmeldungAllesNur das Anmeldeformular

Lebenszyklus

Konten werden bei jedem Boot aus der Konfiguration abgeglichen, in dieser Reihenfolge: der Schema-Interceptor migriert, der Seeder schreibt Konten, dann beginnt das Anmelde-Gate durchzugreifen.

  • HinzufĂĽgen eines Nutzers zur Konfiguration erzeugt ihn.
  • Ă„ndern seines Passworts aktualisiert es. Der Seeder hasht nur neu, wenn das konfigurierte Passwort nicht mehr zum Gespeicherten passt, ein unverändertes Passwort kostet also nur eine Verifikation statt eines frischen Hashes.
  • Ihn aus der Konfiguration zu entfernen deaktiviert das Konto, statt es zu löschen. Seine Konversationen referenzieren seine ID, das Löschen der Zeile wĂĽrde diese Historie also verwaisen lassen, statt den Zugriff zu widerrufen. Er kann sich nicht mehr anmelden; seine Daten bleiben intakt und kehren zurĂĽck, falls das Konto wiederhergestellt wird.
  • Ein passwordEnvVar, dessen Variable ungesetzt ist, ĂĽberspringt dieses Konto vollständig und loggt eine Warnung nach webui-auth. Das schlägt bewusst fehlerabgeschottet fehl — das Konto mit leerem Passwort anzulegen wäre weit schlimmer, als es nicht existieren zu lassen.

Was das nicht ist

Das ist ein fester, von Betreibern bereitgestellter Kontenbestand, kein Benutzerverwaltungssystem. Es gibt keine Selbstregistrierung, kein Passwort-Reset, keine Rollen oder Berechtigungen, und keine Pro-Nutzer-Ratenbegrenzung oder Ausgabendeckel. Wer stattdessen föderierte Identität braucht, bearbeitet resolveUserId() im generierten Handler, um das eigene authentifizierte Prinzipal zurückzugeben — der Rest der Web-UI weiß nicht und interessiert sich nicht dafür, woher die ID kam.

Human-in-the-Loop

Wenn der Agent fĂĽr eine Genehmigung pausiert, emittiert der Stream einen middleware_stop-Chunk ohne Details. Die Seite fragt daher GET /pending?threadId=, was angefragt wird, rendert es mit Genehmigen/Ablehnen und antwortet ĂĽber POST /resume - was die Fortsetzung desselben Turns streamt, sodass das Ergebnis in der Konversation landet, statt eine neue zu beginnen.

decidedBy wird serverseitig aus der Session befĂĽllt, nie aus dem Request-Body: Wer etwas genehmigt hat, ist genau die Art von Behauptung, die ein Aufrufer nicht ĂĽber sich selbst aufstellen dĂĽrfen sollte.

Warning

Ein suspendierter Run gehört der Session, die ihn gestartet hat, und beide Routen erzwingen das. decidedBy serverseitig abzuleiten verhindert nur, dass ein Aufrufer darüber lügt, wer entschieden hat - für sich allein tut es nichts gegen die Frage, wessen Run entschieden wird. Anders als jede andere Action werden /pending und /resume nach threadId statt nach Konversation adressiert, ohne Eigentümerschaftsprüfung könnte ein Besucher mit der threadId einer anderen Person also deren ausstehende Tool-Aufrufe und deren Argumente lesen, und in ihrem Namen genehmigen oder ablehnen.

Der Besitzer braucht keine zusätzliche Buchführung: Der Handler stempelt die session-abgeleitete userId in die Run-Options, und der Agent checkpointet diese Options zusammen mit der Suspendierung - der gespeicherte Zustand weiß also bereits, wem er gehört. /pending antwortet, als sei nichts ausstehend, wenn die Aufruferin nicht die Besitzerin ist, kann also nicht genutzt werden, um zu erproben, ob eine threadId überhaupt existiert; /resume verweigert mit einem 403.

Historie und Neuladen

Das Transkript lebt im DOM; die Konversation lebt im Gedächtnis des Agenten. Ohne Rehydrierung würde ein Neuladen einen leeren Bildschirm zeigen, während der Agent sich weiterhin an alles erinnert - die Seite würde also leer aussehen und dann Folgefragen zu Nachrichten beantworten, die der Nutzer nicht sehen kann. Beim Laden ruft die Seite daher GET <path>/api/history auf und spielt die gespeicherten Nachrichten (Markdown und alles) ab, mit Fallback auf die Willkommensnachricht, falls die Konversation leer ist oder der Abruf scheitert.

New startet eine frische conversationId. Es löscht nichts - die vorherige Konversation bleibt unter ihrer eigenen ID auf dem Server und erscheint in der Seitenleiste, wofür die Konversationstabelle da ist.

Was die Seite tut

Die ausgelieferte Seite ist ein echter Chat-Client, keine Demo-Hülle. Sie liest zuerst GET /info und formt sich nach dem, was der Server tatsächlich meldet, sodass eine Steuerung nur dort erscheint, wo die Fähigkeit existiert.

BereichVerhalten
Konversations-SeitenleisteListet die Konversationen dieses Besuchers, neueste zuerst, mit Nachrichtenzählern. Wechseln, umbenennen (✎), löschen (×), oder eine neue starten. Titel rendern über textContent — ein Titel ist, was der Nutzer zuerst eingegeben hat, wird also nie als Markup geparst
Steuern während des StreamensDer Composer bleibt während eines Turns aktiv. Senden wird zu Steuern, und die Nachricht wird in den bereits laufenden Run eingefügt, statt einen neuen zu starten
StoppPostet /cancel bevor es das Fetch abbricht, damit der Server tatsächlich aufhört, Tokens zu verbrauchen, und behält, was bereits gestreamt wurde
Leeren / KompaktierenLeeren leert diese Konversation; Kompaktieren erscheint nur, wenn ein Zusammenfassungsmodell konfiguriert ist, und meldet, was es tatsächlich getan hat (Compacted 12 messages down to 3, oder Nothing to compact yet)
Reasoning + Tool-AufrufeEingeklappte Aufklappelemente, gefĂĽttert aus delta.reasoning und delta.tool_calls auf derselben HĂĽlle
GenehmigungenEine Human-in-the-Loop-Pause rendert eine Genehmigen/Ablehnen-Karte aus GET /pending, beantwortet ĂĽber /resume, das die Fortsetzung desselben Turns streamt
ThemeServerseitig in preferences gespeichert, folgt also der Identität statt dem Browser. localStorage hält eine lokale Kopie, damit die Wahl einen fehlgeschlagenen Request übersteht
ModellDer Modellname aus /info sitzt im Header, es ist also immer klar, was geantwortet hat

Wiederherstellung zählt mehr, als es klingt. Die zuletzt geöffnete Konversation wird in localStorage gemerkt, aber die Konversationen selbst leben auf dem Server. Existiert diese ID nicht mehr — in einem anderen Tab gelöscht, oder ein frischer Store — fällt die Seite auf die neueste verbleibende Konversation zurück, statt in einen leeren Bildschirm ohne aktive Zeile zu rehydrieren.

Schmale Bildschirme erhalten ein echtes Layout statt eines gequetschten: unter 40rem legt sich die Seitenleiste ĂĽber das Transkript, statt ihm die Breite zu stehlen, und prefers-reduced-motion wird respektiert.

Der SQLite-Store

Jedes webui-Projekt erhält eine SQLite-Datenbank. Sie ist nicht optional, und es gibt keinen Schalter, um sie abzuschalten.

Der Grund ist eine echte Lücke, keine Präferenz: bx-ais IAiMemory hat keine Enumerations-API. Es ist ein Bucket pro (userId, conversationId) — man kann einen lesen, schreiben und leeren, aber nichts darin beantwortet "welche Konversationen hat dieser Nutzer". Eine Konversationsliste, Pro-Nutzer-Präferenzen und alles andere Relationale braucht echten Speicher neben dem Memory, nicht darin.

TeilWas es ist
bx-sqliteDer JDBC-Treiber. Ohne ihn bootet eine webui-App trotzdem, aber jede Query scheitert an einem unbekannten Treiber
qbQueryBuilder fĂĽr Lesen und Schreiben, SchemaBuilder fĂĽr die Tabellen. Nirgendwo handgeschriebenes SQL
models/ChatDb.bxGeneriert. Besitzt das Schema und gibt Query-Builder heraus
interceptors/WebUiSchema.bxGeneriert. Baut ChatDb beim Boot, sodass die Migration dann läuft, nicht bei welchem Request auch immer zuerst die Datenbank berührt

Die Datenquelle wird in Application.bx registriert, und die Grammatik wird in config/ColdBox.bx gepinnt:

// Application.bx (generated)
this.datasources[ "bxagents" ] = {
	"driver"  : "sqlite",
	"database": expandPath( "./data/chat.db" )
}
this.datasource = "bxagents"   // NOT this.defaultDatasource - see below

// config/ColdBox.bx (generated)
qb : {
	defaultGrammar : "SQLiteGrammar@qb",
	defaultOptions : { datasource : "bxagents" }
}

Beide sind pro Eintrag optional zu ĂĽberschreiben:

SchlĂĽsselWas er tutStandard
database.datasourceDer ColdBox-Datenquellennamebxagents
database.pathDie Datenbankdatei, relativ zum App-Root./data/chat.db

Ein absoluter database.path lässt den Build scheitern: Er wird innerhalb der generierten App mit expandPath() aufgelöst, ein absoluter Pfad entkommt also still dem App-Verzeichnis und bricht ein paketiertes .bxa-Deployment.

Das Schema ist versioniert und nur vorwärtslaufend. ChatDb.migrate() zeichnet in einer bxagents_schema_version-Tabelle auf, was es angewendet hat, und wendet nur an, was neuer ist, gegen einen bestehenden Store zu booten ist also ein No-Op. v1 erzeugt conversations und preferences. Es weiterentwickeln, indem eine neue applyV<n>() hinzugefügt und SCHEMA_VERSION erhöht wird — nie, indem eine bereits ausgelieferte Migration bearbeitet wird, denn SQLite kann eine Spalte weder ändern noch löschen, und qbs SQLiteGrammar wirft UnsupportedOperation, statt etwas anderes vorzutäuschen.

Warning

Zwei Dinge hier sind kontraintuitiv, und beide wurden auf die harte Tour gegen einen echten ColdBox-Boot festgestellt, nicht von einer Doku-Seite abgelesen.

Die Standard-Datenquellen-Einstellung ist this.datasource, nicht this.defaultDatasource. Der Registrierungsschlüssel ist Plural (this.datasources[ "name" ]), der singuläre Standard liest sich also, als sollte er passen - und BoxLang akzeptiert this.defaultDatasource still und tut nichts damit. Der dadurch erzeugte Fehler nennt genau die Datenquelle, die ausgewählt werden soll (No default datasource defined in the application or globally or in the query options. Registered datasources are: [bxagents]), was sich wie ein kaputter Auswahlmechanismus liest statt wie eine falsch geschriebene Einstellung.

Die Datenquelle an jedem qb-Builder benennen; sich nicht auf moduleSettings.qb.defaultOptions verlassen. qbs ModuleConfig.cfc mappt QueryBuilder@qb mit .initArg( name = "defaultOptions", value = settings.defaultOptions ) in onLoad(), die Einstellung sieht also so aus, als decke sie einen ab. In einem echten Boot kam sie nicht an - die Datenquelle war registriert, und der Builder hatte trotzdem leere Optionen. ChatDb.query() ruft daher .mergeDefaultOptions( { datasource : static.DATASOURCE } ) auf jedem Builder auf, den es herausgibt. SchemaBuilder@qb erhält defaultOptions überhaupt nie (qb mappt es nur mit grammar), jeder Schema-Aufruf übergibt daher selbst options: { datasource: ... }.

Der moduleSettings.qb-Block wird weiterhin generiert - er ist richtig für jede andere qb-Nutzung in der App - aber der generierte Store hängt nicht davon ab.

Wer ChatDb erweitert, sollte die Datenquelle auf allem benennen, was hinzugefĂĽgt wird.

Noch eines, unverändert: Die Datenquelle muss eine benannte Datenquelle sein, nie eine Inline-Struktur - qbs eigenes appendSqlComments() typisiert dieses Argument als string, eine Struktur wirft also, bevor je ein SQL läuft.

Die Grammatik ist das einzige SQLite-spezifische Stück. Alles andere läuft über qb, das später auf Postgres oder MySQL zu richten ist also eine Grammatik- und Datenquellenänderung statt eines Rewrites.

Konversationen und Präferenzen

DafĂĽr existiert der SQLite-Store, und beide sind auf dieselbe serverseitig abgeleitete userId gescoped wie alles andere.

Konversationen. Jeder Turn über /invoke, /stream oder /batch trägt sich selbst in den Index ein: Die Zeile wird bei der ersten Nutzung erzeugt, updatedAt bewegt sich, und die erste Nutzernachricht wird zum Titel (auf eine Zeile zusammengefasst, auf 60 Zeichen gekürzt), sofern nicht bereits einer gesetzt ist — eine Umbenennung wird also nie still vom nächsten Turn rückgängig gemacht. messageCount ist ein Anzeigezähler, pro Turn um zwei erhöht; ein Turn, der auf halbem Weg stirbt, kann ihn um eins zu hoch lassen, und /clear setzt ihn zurück. Das eigene Gedächtnis des Agenten bleibt die Autorität darüber, was tatsächlich gesagt wurde.

/conversations/delete entfernt die Indexzeile und leert die Nachrichten des Agenten für diese Konversation. Nur die Zeile zu löschen würde die Konversation unsichtbar lassen, während sie in dem Moment, in dem jemand die ID wiederverwendet, immer noch im Kontext des Modells säße.

Warning

Warum touchConversation() kein qb-Upsert ist. Ein Upsert zielt allein auf den Primärschlüssel, ein Aufrufer, der die conversationId einer anderen Person erraten hätte, würde also die eigene userId auf diese Zeile schreiben und die Konversation übernehmen. Der Store liest zuerst und verweigert, wenn die Zeile jemand anderem gehört. setPreference() upsertet doch, und sicher — sein Ziel ist der zusammengesetzte (userId, prefKey)-Schlüssel, die eigene Identität der Aufruferin ist also Teil dessen, worauf sie matcht.

Präferenzen. Serverseitig statt localStorage, sie folgen also der Identität statt dem Browser. identifierProvider auf ein echtes authentifiziertes Prinzipal zu richten lässt die Präferenzen eines Besuchers ihm über Geräte hinweg folgen, ohne eine Änderung am generierten Code.

Branding und Theming

Jeder SchlĂĽssel unten ist optional - der Eintrag funktioniert mit nur exposes und path.

SchlĂĽsselWas er tut
titleBrowser-Titel und Header-Ăśberschrift
subtitleKleine Zeile unter der Ăśberschrift
iconEin Emoji (gerendert als Inline-SVG-Favicon und im Header) oder eine Bild-URL/ein Pfad (/logo.svg, https://…, data:image/…)
welcomeLeerzustands-Nachricht, angezeigt vor dem ersten Turn
placeholderComposer-Eingabe-Platzhalter
footerKleiner Hinweis unter dem Composer - Disclaimer, Links
showReasoningDen "Denkt nach"-Streifen zeigen. Standard true
showToolCallsTool-Aufruf-Chips zeigen. Standard true
themeDesign-Tokens - siehe unten
themeFilePfad zu einem CSS-Override, relativ zum Projekt-Root. Standard resources/webui/theme.css

theme bildet direkt auf die CSS-Custom-Properties der Seite ab: accent, accentFg, bg, fg, muted, border, surface, inputBg, bubbleUser, bubbleUserFg, bubbleAssistant, bubbleAssistantFg, bubbleError, reasoningFg, reasoningBg, toolFg, toolBg, radius, radiusSm, font, fontMono, fontSize, maxWidth. Ein verschachtelter theme.dark-Block überschreibt beliebige dieser Tokens für den Dark Mode. Ein unbekanntes Token lässt den Build scheitern, statt still ignoriert zu werden, ein Tippfehler wird also sofort sichtbar, statt sich zu fragen, warum die eigene Markenfarbe nie erschienen ist.

// gateways/chat.bx
theme: {
	accent : "0f766e",
	radius : "10px",
	font   : "Inter, system-ui, sans-serif",
	dark   : { accent : "rgb(45, 212, 191)" }
}
Info

Hex-Farben nackt schreiben, ohne fĂĽhrendes Rautezeichen. BoxLang beginnt String-Interpolation bei # sowohl in einfach als auch doppelt gequoteten Strings, eine literale Hex-Farbe in einer .bx-Konfiguration ist also ein Parse-Fehler, sofern die Raute nicht verdoppelt wird - eine Falle, an die niemand denkt. Der Generator fĂĽgt sie selbst wieder hinzu, "0f766e" funktioniert also einfach so. rgb(), hsl() und benannte Farben brauchen so oder so nichts Besonderes.

Für alles, was die Tokens nicht abdecken - eigene Schriften, Layout, Regeln pro Element - eine resources/webui/theme.css ins Projekt legen. Sie wird zuletzt in den <style>-Block der Seite eingebettet, schlägt also sowohl die ausgelieferten Standardwerte als auch die theme-Tokens; und da sie eine echte .css-Datei ist, funktioniert gewöhnliches #rrggbb-Hex dort ganz normal. (Ein literales </style in dieser Datei lässt den Build scheitern, da es den Style-Block der Seite vorzeitig beenden würde.)

Warning

apiKeyEnvVar ist ein einfaches, umschaltbares Gate - kein vollständiges Anmeldesystem. Bleibt es ungesetzt, ist <path>/api/* weit offen (fein für die lokale Entwicklung, nicht für ein öffentliches Deployment). Wird es gesetzt, verlangt ein generierter preProcess-Interceptor (interceptors/WebUiAuthGate.bx), dass jeder Request unter <path>/api/* einen passenden X-API-Key-Header trägt, verglichen über java.security.MessageDigest.isEqual() - dieselbe zeitkonstante Vergleichsdisziplin, die jede Webhook-Gateway-eigene Signaturprüfung bereits nutzt. Die statische Hülle selbst (<path>/index.html) ist bewusst NICHT gesperrt - nur <path>/api/* ist es - weil eine gewöhnliche Seitennavigation eines Browsers keinen benutzerdefinierten Header senden kann, das Sperren der Hülle würde also genau die Seite unerreichbar machen, die nach dem Schlüssel fragt, ohne ihn bereits zu haben. Das eigene JS der Seite fragt nach dem Schlüssel (eine "Schlüssel"-Schaltfläche, in localStorage gespeichert) und sendet ihn von da an bei jedem eigenen API-Aufruf.

Konversationsidentität: die Session IST der Nutzeridentifikator

Jedes Gedächtnis, das ein Agent hält, ist nach (userId, conversationId) geschlüsselt - und ein Agent kann mehrere gleichzeitig halten (AiAgents memories ist ein Array; loadMemoryMessages() iteriert über alle mit demselben Paar). AiAgent.run()/.stream() fallen für beide auf "" zurück, wenn nichts sie liefert, ohne serverseitige Identität landet also jeder Besucher in einem gemeinsamen Bucket, unabhängig davon, welche Memory-Typen konfiguriert sind.

Die Lösung ist Identität, kein Memory-Typ. Ein Projekt mit einer webui-Exposure erhält daher:

  1. Session-Verwaltung an im generierten Application.bx - this.sessionManagement = true, this.setClientCookies = true, ein 60-minĂĽtiges sessionTimeout. Cookies sind tragend: kein Cookie, keine Session-ID.
  2. Ein eigenes handlers/ChatUi.bx, das ColdBoxs getUserSessionIdentifier() als userId des Agenten an allen drei Runner-Formen ĂĽbergibt - invoke, stream und batch.
// handlers/ChatUi.bx (generated)
private string function resolveUserId() {
	return controller.getUserSessionIdentifier()
}

An ColdBox statt direkt an session.sessionId zu delegieren bringt drei Dinge: Die ID ist pro Anwendung präfixiert, sie fällt bei nicht verfügbarer Session auf URLToken/CFID zurück, und - was am meisten zählt - sie respektiert die Konfigurationseinstellung identifierProvider. Wer die auf das eigene authentifizierte Prinzipal richtet, dessen Gedächtnis schlüsselt sich ohne Änderung am generierten Handler auf den echten Nutzer um.

Weil die Identität serverseitig ausgestellt ist, hält der Scope, egal welche Memories das Projekt konfiguriert - eine oder mehrere, window, cache, jdbc, Vektor, jede Mischung.

Info

Warum nicht toAi() für die Web-UI? ColdBox 8.1s toAi() leitet Konversationskontext jetzt selbst ab, und sein Fallback ist genau derselbe Aufruf, den dieser Handler macht: len( body.userId ) ? body.userId : controller.getUserSessionIdentifier(). Der Unterschied ist die Priorität - toAi() lässt eine vom Aufrufer mitgelieferte userId gewinnen, was für einen vertrauenswürdigen Server-zu-Server-Aufrufer richtig ist, aber falsch für einen Browser, der hinter einem gemeinsamen API-Schlüssel sitzt, wo sich jeder als jemand anderes ausgeben und das Gedächtnis eines anderen Besuchers lesen könnte. Der generierte Handler leitet die Identität ausschließlich serverseitig ab und schaut nie auf body.userId. Er behält toAi()s exakte Routenform (/invoke, /stream, /batch, /info), sein SSE-Drahtformat, und sein X-Thread-Id-/thread-Event-Echo, bleibt also austauschbar. Die anderen Exposure-Arten (exposes: "agent") nutzen weiterhin unverändert toAi() - Server-zu-Server ist der Fall, für den dessen Priorität gebaut ist.

conversationId kommt weiterhin vom Client, und das ist beabsichtigt: Es unterscheidet mehrere Konversationen, die zum selben Besucher gehören - es ist das, was die New-Schaltfläche rotiert. Es ist nicht die Isolationsgrenze; das ist die session-abgeleitete userId.

Kein Memory-Typ wird erzwungen. Einen (oder mehrere) pro Agent mit einem memory-Schlüssel an Agent.bx wählen, gleiche Form wie checkpointer:

// Agent.bx
memory: { type: "cache", maxMessages: 50 }

Ein Projekt ohne webui hält Sessions aus und bx-ais eigenen Memory-Standard - eine reine API-/Gateway-App hat keinen Browser zu verfolgen, und eine Session dort ist Overhead plus ein Cookie, um das niemand gebeten hat.

Rendering von Antworten

Assistenten-Antworten rendern über eine bewusst kleine Markdown-Teilmenge - Fenced- und Inline-Code, fett/kursiv, Links, Aufzählungs- und nummerierte Listen, Überschriften. Sie wird escape-first angewendet: Der Text des Modells wird HTML-escaped, bevor auch nur ein einziges Tag eingeführt wird, keine Modellausgabe kann also zu lebendem Markup werden, und Link-hrefs sind auf http(s)/mailto allowlisted, sodass eine javascript:-URL nie in einen Anker verwandelt wird.

Info

Der Composer ist ein textarea - Enter sendet, Shift+Enter fügt einen Zeilenumbruch hinzu, und er wächst auf etwa sechs Zeilen, bevor er scrollt. Ein laufender Turn kann mit Stopp angehalten werden (ein AbortController), das behält, was bereits gestreamt wurde, statt es zu verwerfen. Das Transkript scrollt nur automatisch, wenn man ohnehin bereits am unteren Rand ist, damit hochzuscrollen, um während des Streamens etwas nachzulesen, einen nicht wieder nach unten reißt.

What is not here yet

Die Seite ist gegen die eigene API vollständig - jede benötigte Route existiert und wird geprüft. Das sind die Lücken:

FehltHinweis
Anhänge / BildeingabeDer Composer ist reiner Text. bx-ai selbst handhabt Bilder, das ist also eine UI-Lücke, keine Capability-Lücke
Wiederholen / neu generierenEin fehlgeschlagener Turn muss von Hand erneut gesendet werden
Bearbeiten und erneut sendenKeine Bearbeitung einer bereits gesendeten Nachricht
Token-/KostenanzeigeNichts zeigt die Nutzung an, obwohl der Provider sie zurĂĽckgibt

Siehe Bekannte Einschränkungen dafür, was gegen einen echten ColdBox-Boot verifiziert wurde und was nicht, einschließlich der Teile dieser Seite, die nur durch Assertions auf Generator-Ebene abgedeckt sind, statt durch das Steuern eines Browsers.

Edit this page Download Markdown Last updated Aug 21, 2026, 6:33:28 PM