@cnt-dev/chat-ui
v0.5.2
Published
Embeddable AI chat UI control for UI5 applications — sidebar-based conversations, streaming answers, file attachments, rich UI5-control bubbles.
Readme
@cnt-dev/chat-ui
Eine schlanke, einbettbare UI5-Chat-Library mit genau einer Kontrolle:
chatui.ChatBox. Die Library ist bewusst transport-agnostisch —
sie kennt kein Backend, keine Session, keine Auth. Sie feuert send und
erwartet, dass die einbettende App die Antwort per API zurückschiebt.
- Zero-Deps außer
sap.ui.core(keinsap.m, keinsap.viz). - Streaming-fähig via Chunk-API.
- Konversations-Sidebar, File-Uploads, Rich Content, Editing, Copy out of the box.
- npm-Name:
@cnt-dev/chat-ui— UI5-Namespace / Modulpfad:chatui/chatui/….
Feature-Übersicht
| Feature | API |
|--------------------------------|----------------------------------------------------------|
| Text-Nachricht senden | send-Event → {message, files, conversationId} |
| Streaming-Antwort | addAssistantMessage("") + appendToLastAssistantMessage(chunk) |
| Markdown-Rendering | automatisch bei Assistant-Text |
| Copy-to-Clipboard | Hover-Action pro Assistant-Bubble |
| Datei-/Bild-Uploads | Paperclip-Button + Drag&Drop → files: File[] im send |
| Nachrichten-Bearbeitung | Edit-Action pro User-Bubble, truncated + refire send |
| Rich Content (Chart, Card, …) | addAssistantControl(control) |
| Konversations-Sidebar | conversations-Property + conversationSelect / conversationDelete-Events |
| Restore aus Persistenz | setConversations() / getConversations() (und setMessages / getMessages pro Konversation) |
| Neue Konversation | Header-Button „+ Neue Konversation" → newChat-Event |
| i18n | DE (default) + EN (chatbox.*-Keys) |
| Themes | sap_fiori_3, _dark, _hcw, _hcb |
Installation
npm i @cnt-dev/chat-uiIn der Consumer-ui5.yaml:
framework:
libraries:
- name: sap.ui.core
- name: chatuiIn manifest.json:
"sap.ui5": {
"dependencies": {
"libs": {
"chatui": {}
}
}
}Bei nicht-UI5-tooling-Bootstraps ggf. Ressourcen-Root explizit setzen:
<script id="sap-ui-bootstrap"
src="…/sap-ui-core.js"
data-sap-ui-libs="chatui"
data-sap-ui-resourceroots='{"chatui": "node_modules/@cnt-dev/chat-ui/resources/chatui"}'>
</script>Schnellstart
import ChatBox from "chatui/ChatBox";
import type {
ChatBox$SendEvent,
ChatBox$ConversationDeleteEvent,
Conversation
} from "chatui/ChatBox";
let conversations: Conversation[] = loadFromStorage();
const chat = new ChatBox({
width: "100%",
height: "100%",
send: async (e: ChatBox$SendEvent) => {
const message = e.getParameter("message") ?? "";
const files = (e.getParameter("files") as File[] | undefined) ?? [];
let id = e.getParameter("conversationId") ?? "";
if (!id) { // neue Konversation
id = crypto.randomUUID();
chat.setActiveConversationId(id);
}
upsert(conversations, id, chat.getMessages());
chat.setConversations(conversations);
chat.setBusy(true);
chat.addAssistantMessage(""); // pending bubble
for await (const chunk of streamFromBackend(message, files, chat.getMessages())) {
chat.appendToLastAssistantMessage(chunk);
}
chat.setBusy(false);
upsert(conversations, id, chat.getMessages());
chat.setConversations(conversations);
},
newChat: () => backend.resetSession(),
conversationDelete: (e: ChatBox$ConversationDeleteEvent) => {
const id = e.getParameter("id") ?? "";
conversations = conversations.filter(c => c.id !== id);
chat.setConversations(conversations);
if (chat.getActiveConversationId() === id) {
chat.setActiveConversationId(conversations[0]?.id ?? "");
}
}
});
chat.setConversations(conversations); // Library wählt automatisch die erste
chat.placeAt("content");Public API
Properties
| Name | Typ | Default | Zweck |
|-------------------------|------------------------|---------|-------|
| placeholder | string | "" | Platzhaltertext (leer → i18n-Default) |
| busy | boolean | false | Deaktiviert Input, Buttons, Edit |
| width | sap.ui.core.CSSSize | 100% | Passt sich Parent an |
| height | sap.ui.core.CSSSize | 100% | Passt sich Parent an |
| allowAttachments | boolean | true | Blendet Paperclip + Drop-Zone aus, wenn false |
| attachmentAccept | string | "" | 1:1 an <input type="file" accept="…"> (z.B. "image/*") |
| conversations | Conversation[] | [] | Sidebar-Einträge (vom Consumer gehalten) |
| activeConversationId | string | "" | Aktive Konversation. "" = neuer Chat-Draft |
Events
send({message, files, conversationId})User hat einen Prompt und/oder Anhänge abgeschickt.conversationIdist die aktive Id oder""bei einem neuen Chat — dann vergibt der Consumer eine frische Id und pusht eine aktualisierte Liste zurück.newChatUser hat „+ Neue Konversation" oben rechts gedrückt. Transcript undactiveConversationIdsind bereits zurückgesetzt; Handler räumt serverseitige Session-States auf.conversationSelect({id})User hat eine Konversation in der Sidebar angeklickt. Die Library hat die Ansicht bereits umgeschaltet.conversationDelete({id})User hat auf das × einer Konversation geklickt. Die Library mutiertconversationsnicht selbst — der Consumer entfernt den Eintrag aus seinem Store und pusht viasetConversationszurück.
Methoden
addAssistantMessage(text: string): thisHängt eine Assistant-Text-Bubble an. Leerer Text = „pending"-Bubble mit Animation.appendToLastAssistantMessage(chunk: string): thisStreaming: hängt Token an die letzte Assistant-Text-Bubble.addAssistantControl(control: sap.ui.core.Control): thisHängt ein beliebiges UI5-Control als eigene Assistant-Bubble an. Die Library übernimmt den Lifecycle — Control wird beiclear(),newChat,setMessages()oder ChatBox-Destroy mitdestroy()aufgeräumt.clear(): thisLeert Transcript ohnenewChat-Event zu feuern.getMessages()/setMessages(msgs)Snapshot / Ersetzen des Transcripts der aktiven Konversation. Control- Refs werden vonsetMessagesbewusst nicht übernommen.getConversations()/setConversations(convs)Snapshot / Ersetzen der Sidebar-Liste. Beim ersten Setzen mit non-empty Array wird automatisch die erste Konversation aktiv und derenmessagesin den Transcript geladen.getActiveConversationId()/setActiveConversationId(id)Programmatischer Wechsel. Unbekannte Id (inkl."") → Transcript geleert (neuer-Chat-Draft-State).
Message- und Conversation-Struktur (exportiert)
interface Message {
role: "user" | "assistant";
content: string;
files?: File[];
control?: sap.ui.core.Control;
}
interface Conversation {
id: string;
title?: string; // fallback: erste User-Message
messages?: ReadonlyArray<Message>;
updatedAt?: number | string | Date; // heute → HH:MM, gestern, Wochentag, Datum
}Consumer-Muster
Persistenz per localStorage
interface Stored { id: string; title?: string; updatedAt?: number;
messages: { role: "user"|"assistant"; content: string }[]; }
function load(): Stored[] { try { return JSON.parse(localStorage.getItem("chats") ?? "[]"); } catch { return []; } }
function save(c: Stored[]) { localStorage.setItem("chats", JSON.stringify(c)); }
const stored = load();
chat.setConversations(stored); // Library wählt automatisch die ersteRich Content (Chart als Assistant-Bubble)
import VizFrame from "sap/viz/ui5/controls/VizFrame";
// Die Deps für VizFrame liegen in der Consumer-App, nicht in der Library.
const chart = new VizFrame({ vizType: "column", /* bindings … */ });
chat.addAssistantControl(chart);App-Kontext ans Backend mitschicken
Die Library reicht Kontext bewusst nicht durch — jede App kennt ihren Kontext selbst und packt ihn ins Request-Payload:
send: async (e) => {
const message = e.getParameter("message");
const files = e.getParameter("files") as File[];
const context = {
route: window.location.hash,
selection: model.getProperty("/selection"),
clAudeMd: await fetch("./CLAUDE.md").then(r => r.text()),
};
const reply = await backend.chat({ message, files, context });
// …
}Design-Grenzen (bewusst außerhalb)
- Kein Backend-Wissen. Auth, Session, Transport, Retry — alles Consumer.
- Kein Streaming über
fetch/SSE. Die Library bietet nur die Streaming-API (appendToLastAssistantMessage); die Netzwerk-Schicht bringt der Consumer mit. - Kein Upload-Transport. Files werden gesammelt und im
send-Event durchgereicht — wie sie zum Backend gehen, entscheidet der Consumer (multipart, base64, S3-presigned, …). - Kein Storage.
getMessages/setMessagesbilden den Roundtrip, Storage bringt der Consumer. - Keine
sap.m- /sap.viz-Dependencies. Rich Content wird peraddAssistantControlreingereicht; die Library kennt weiterhin nursap.ui.core.Control.
Entwicklung
npm run build-interface # regeneriert ChatBox.gen.d.ts aus dem Control-Code
npm run ts-typecheck # tsc --noEmit
npm run lint # eslint
npm test # karma + qunit, headless Chrome
npm start # startet fiori run mit test/ChatBox.html
npm run build-app # ui5 build → dist/Nach Änderungen an Properties/Events/Aggregations in ChatBox.ts muss
build-interface laufen, damit die .gen.d.ts-Metadata nicht driftet.
Publish auf npm
Die Root-package.json ist private: true und trägt nur die Dev-
Metadaten. Publiziert wird ausschließlich der Build-Output unter dist/
mit einer generierten, schlanken package.json, so dass der Consumer
node_modules/@cnt-dev/chat-ui/resources/chatui/… direkt vorfindet.
npm run pack-dist # baut + prepped + erzeugt einen Tarball zum Prüfen
npm run publish-dist # baut + prepped + npm publish ./dist (npm login vorausgesetzt)scripts/prepare-publish.mjs schreibt dist/package.json, dist/ui5.yaml
und kopiert README.md, LICENSE, AGENTS.md in dist/ hinein.
Version anheben (npm version patch|minor|major) vor dem Publish.
Verzeichnisstruktur
chatui/
├── src/chatui/
│ ├── ChatBox.ts # Hauptkontrolle (inline renderer)
│ ├── ChatBox.gen.d.ts # generierte Interface-Deklaration
│ ├── markdown.ts # kleiner Markdown→HTML-Konverter
│ ├── library.ts # Lib.init() — registriert ChatBox
│ ├── .library # Library-Metadaten
│ ├── messagebundle*.properties # i18n (DE default, EN)
│ └── themes/… # sap_fiori_3(_dark|_hcw|_hcb)
├── test/chatui/
│ ├── ChatBox.html # `npm start`-Einstiegspunkt
│ ├── ChatBox.ts # dummy Send-Handler (Echo-Stream)
│ └── qunit/*.qunit.ts # Unit-Tests
└── ui5.yaml, tsconfig.json, package.json, karma.conf.jsAusführliche interne Hintergründe (Design-Entscheidungen, ESLint-Ausnahmen,
Referenzmaterial) stehen in ../CLAUDE.md.
