Chat UX

How the chat window streams a response, renders Markdown safely, surfaces confidence and grounding, captures feedback, and saves or exports the conversation.

For frontend developers. The behaviour of ChatBotComponent.vue and its satellite dialogs: the streaming send, the confidence chip, the grounding warning, the feedback dialog, and save / export flows.

Prerequisites

  • Read State Management — the auth module owns the access token used in the streaming request.
  • Keycloak access token present (the chat window will not render the input until currentUser is truthy).
  • The backend BFF exposes POST /api/queries/stream (SSE) and POST /api/queries/{queryId}/feedback. See Backend API Contracts.

Chat window anatomy

src/components/ChatBotComponent.vue is the main chat surface. From top to bottom:

RegionElementNotes
HeaderToolbar — New chat (Plus), Save, Export, SendTriggers startNewChat, saveChatToHistory, openExportDialog, sendMessage.
Message listPer-message bubbles — sender (user / bot), timestamp, optional confidence/grounding chip + feedback buttonBot bubbles render Markdown via renderMarkdown() (see Markdown rendering).
Streaming indicatorInline DsSpinner size="sm" + chatbot.thinking textShown while msg.isStreaming && !msg.content.
Status pillchatbot.online / chatbot.offline + last response timeUpdates after each completed response.
Context pillsSelected service-tree items injected as a filterEmit treeNodeSelected from ServiceTreePanelComponent.
Input areaMultiline text area + send buttonNot bound to isStreaming for disabled. Pressing Enter (with or without Shift) sends the message — DsInput.vue @keyup.enter fires on any Enter, no shiftKey check; Shift+Enter does NOT insert a newline.
Quick-help overlayShown when showQuickHelp && selectedContextItems.length === 0Cards for common questions; click sends the prompt.

The message list scrolls automatically: a messagesEnd ref is mutated whenever the messages array changes. Manually scrolled-up content stays put until the user scrolls back to the bottom (no auto-jank on long histories).

Streaming send

The send path is input → chatbotService.submitQueryStream() → POST /api/queries/stream (SSE) and back. The service uses fetch directly (axios cannot stream), creates an AbortController, and dispatches each data: SSE frame to a typed callback.

// chatbotService.submitQueryStream signature
submitQueryStream(queryData, { onChunk, onMetadata, onTranslation, onDone, onError }) → AbortController

// Caller (ChatBotComponent)
this.streamController = chatbotService.submitQueryStream(
  { message: text, context: this.selectedContextItems, conversationId: this.currentChatId },
  {
    onChunk: (content) => { /* append to last bot message */ },
    onMetadata: (meta) => { /* confidence + is_grounded */ },
    onTranslation: (content) => { /* streaming translation in target locale */ },
    onDone: (data) => { /* finalize + persist */ },
    onError: (err) => { /* show toast, mark message failed */ }
  }
);

Behaviour during the stream

EventFrame typeUI effect
First token arriveschunkSpinner → text. Message marked isStreaming = true.
Intermediate tokenschunkAppend to messages[i].content. Re-render Markdown.
Streaming translation (if enabled)translationUpdates the translation pane / replaces the displayed locale.
Metadata arrivesmetadataSets confidenceScore and isGrounded on the message. Triggers the confidence chip or grounding flag.
Heartbeat / keepalive: keepalive (line starts with :)Ignored by the parser.
Stream ends cleanlydoneisStreaming = false. Saves final content + metadata.
Stream errorserrorToast + marks the message as failed. Allows retry.

Cancellation: starting a new send

When the user submits a new message while a stream is in flight, ChatBotComponent calls this.streamController.abort() before opening the new stream. The previous fetch fails with AbortError; the service’s catch branch suppresses it (if (error.name === 'AbortError') return;) — only the new stream’s events drive the UI.

Markdown rendering and sanitisation

Bot messages render Markdown to HTML and pipe through DOMPurify:

// ChatBotComponent.vue (~line 696)
renderMarkdown(content) {
  if (!content) return '';
  const html = marked.parse(content);
  return DOMPurify.sanitize(html);
}

Two customisations matter:

  1. Tight-list renderer override — GFM lists are emitted as <li><p>label</p><ul>...</ul></li> which produces double-spaced output. ChatBotComponent injects a custom tightListRenderer (via marked.use({ renderer })) so nested bullets render compact.
  2. HTML sanitisation — DOMPurify.sanitize strips <script>, inline event handlers, and javascript: URLs before the HTML is inserted via v-html. User messages are never rendered as Markdown — they are shown as plain text only.

The combination means: even a prompt-injected response from the LLM cannot reach the page as raw HTML.

Confidence chip and grounding flag

Backend metadata arrives on the metadata SSE frame and is attached to the last bot message. The UI renders one of three states:

Backend metadata.is_groundedBackend confidence_scoreUI
true (or omitted)non-null numberBrain icon + Confidence: NN% chip
true (or omitted)nullNo chip
falseanySparkles icon + chatbot.aiGeneratedNoDocs (“AI-generated — not based on library documents”)
AnyAnyFeedback button always visible

The chip and the grounding flag are mutually exclusive (v-if / v-else-if). The Sparkles warning is the visible signal that the answer was synthesised from the LLM’s general knowledge rather than retrieved from the knowledge base — important for public-sector deployments where hallucination risk must be flagged.

The confidence_score is the reranker-confidence mean from the RAG pipeline (Architecture → Confidence Scoring). It is not the LLM’s self-reported confidence (the [[CONF:NN]] sentinel) — those are different signals; only the reranker score flows to the UI chip.

How to interpret

  • High confidence (≥ 80 %) — typically a strong match in the knowledge base.
  • Low confidence (≤ 40 %) — answer is likely correct in form but weakly grounded. Don’t trust without domain expertise.
  • Not grounded — treat as a general-LLM answer, regardless of how confident it sounds.

The chip is informational, not actionable. The user can always click the feedback button to rate the response and improve future ranking.

Feedback dialog

ChatResponseFeedbackDialog.vue opens per bot message when the user clicks the feedback button. Two columns:

Left columnRight column
Thumbs up / down (with skin-tone selector)1–5 rating scale (i18n labels)
Message preview (read-only)Free-text comment field

Behaviour

  • The user must select either a thumb or a rating before submitting — both are optional individually, but at least one is required (the Submit button is disabled otherwise).
  • The skin-tone selector (thumbFeedback === 'up' ? skinToneColor : 'none') draws from a small palette stored on the component. Selection is NOT persisted across sessions — it lives on the dialog instance only.
  • Comments are sanitised before submission (no HTML, length capped server-side).
  • On submit: chatbotService.submitFeedback(queryId, { thumb, rating, comment }) — POST /api/queries/{queryId}/feedback. On success, the dialog closes and a toast appears. On failure, an inline error stays in the dialog.
  • Ratings aggregate into the admin analytics satisfaction tiles — see Dashboards.

Save / export / new-chat flows

All three flows live inside ChatBotComponent.vue and share the same confirmation primitives.

Save chat

StepBehaviour
1. User clicks SaveOpens saveChatDialog — title input + folder <DsCombobox> (existing folders + new-folder inline).
2. SubmitchatHistoryService.createConversation(...) → chatHistory.ADD_CHAT mutation → plugin writes localStorage. The new chat id is set as currentChatId.
3. Already in a chat?Save updates the existing record (UPDATE_CHAT) instead of creating a duplicate.

The default folder is default (All Chats); selecting a different folder triggers addChatToFolder and updates the sidebar selection.

Export

StepBehaviour
1. User clicks ExportOpens exportDialog — currently PDF only.
2. Formatnew jsPDF() with a custom Markdown → PDF parser. The parser walks the marked lexer tokens (line ~1604: const tokens = marked.lexer(markdown)) and renders paragraphs, headings, lists, and code blocks with manual page-break logic.
3. Filenamechat-{shortId}-{YYYY-MM-DD}.pdf. The blob is offered as a download.

The Markdown → PDF converter handles only the LLM-emitted subset (paragraphs, lists, code, headings). Tables are flattened; embedded images are skipped. This is intentional — the export is meant for archiving a conversation, not a high-fidelity print.

New chat + load chat confirmations

Switching chats (or starting a new one) when the current chat has unsaved changes triggers ConfirmDialog:

DialogTitleOptions
New chat while unsaved“You have unsaved changes”Save & continue / Discard / Cancel
Load another chat while unsaved“You have unsaved changes”Save & switch / Discard & switch / Cancel
Quick-help overlay replaces nothing—No prompt.
Dismissing a chat with feedback pending“Submit feedback first?”Submit / Discard / Cancel

The unsaved-state heuristic is purely client-side: any text in the input area that hasn’t been sent yet, or any change to context pills that hasn’t been persisted. There is no formal “draft” flag — the heuristic is conservative (false positives are cheap; false negatives lose work).

Quick-help overlay

The overlay appears on a fresh currentChatId (no prior messages) and when no context items are selected. The cards come from config.features.chat.quickHelp.buttons in the runtime config loaded by main.js (ChatBotComponent.vue:706-744), not from the backend. Display text and prompt text are resolved per locale via resolveConfigText(...).

A card click has two effects:

  1. The card’s title populates the input area as a “prefilled prompt” (still requires the user to send). Title is resolved per locale.
  2. The card’s action.hiddenPrompt is the actual LLM-facing prompt — action.visibleText is the friendly hint shown to the user; hiddenPrompt can be richer and is sent in the queryData payload.
Card config (each entry in config.features.chat.quickHelp.buttons):
{
  "id": "agriculture.subsidies",
  "title": "How do I apply for a farming subsidy?",
  "action": {
    "visibleText": "How do I apply for a farming subsidy?",
    "hiddenPrompt": "Explain the eligibility criteria, required documents, and processing time for the agriculture subsidy programme for smallholder farmers."
  },
  "service": "Agriculture",
  "serviceKey": "agriculture",
  "serviceLabels": ["agriculture", "subsidies", "rural-development"],
  "category": "agriculture",
  "icon": "agriculture.svg"
}

service is the localized display name; serviceKey / serviceLabels are the English KB labels used as the retriever filter. Display and filter are decoupled so the button always shows its localized title while the filter always uses English KB labels.

Accessibility

ElementTreatment
Message listaria-live="polite" — screen readers announce new bot tokens as they stream.
Streaming indicatoraria-busy="true" while isStreaming. Cleared when content arrives.
Input areaaria-label from chatbot.inputPlaceholder. Disabled state announces via aria-disabled.
Feedback buttonaria-label from feedback.button translation. Selected state via aria-pressed.
Context pillsEach pill is a <button> with aria-label describing the category + a remove control.
Quick-help cards<button> elements, focusable in tab order, keyboard activation on Enter / Space.

The skin-tone selector on the feedback dialog is keyboard-operable via click; each swatch has an aria-label like “Skin tone 1”, “Skin tone 2”, etc. (hardcoded in ChatResponseFeedbackDialog.vue:87, not i18n).

Failure modes & troubleshooting

SymptomCauseFix
Stream starts but no tokens appearBackend returned 200 with empty body, or the SSE parser skipped all frames.Check DevTools Network → the /queries/stream request. If text/event-stream is missing, the backend is misconfigured.
Stream cuts off mid-responseNetwork blip, server restart, or the user’s tab went to sleep.The message stays in isStreaming=true state with partial content. The user can click retry (re-sends the prompt).
Token expired mid-streamThe Authorization: Bearer header was rejected after rotation.httpService 401 interceptor should re-auth; verify the silent-renew callback is registered (store/modules/auth.js:37-53).
Confidence chip missingBackend never sent the metadata frame, or sent confidence_score: null.Inspect the SSE frames; both are valid (chip is gated on confidenceScore != null && isGrounded !== false).
Grounding flag stays after a fixThe bot message’s isGrounded is sticky once metadata arrives.Re-send the message — each response carries fresh metadata.
PDF export is emptyThe chat history has no marked.parse-able content (e.g. only user messages).Export requires at least one bot message; user-only chats are skipped with a toast.
Feedback submit 400sBackend feedback validator rejected the payload (rating out of range, comment too long).Server-side validator; trim comment and re-submit.