AquaGPT — AI Assistant
The in-app AI assistant — a streaming, multi-agent chatbot that answers natural-language questions about your water data ("Analyse my water balance and share insights", "Show current water consumption across all meters").
At a glance
| Route | /aquagpt — in every app → AquaAi (libs/aquaAi/src/AquaAi.jsx) |
| Permission | AQUAGPT gates the sidebar entry; the route itself is not wrapped |
| Library | libs/aquaAi/ — the live implementation |
| Backend | A separate AI-agent service, not the main AquaGen API |
| Transport | fetch + Server-Sent Events; no AI SDK |
| Auth | A session_id in the request body — not the raw-JWT apiClient |
| State | useReducer — the one feature lib that does not use the house Context + useState pattern |
| History | None — no conversation-history API |
| Dates | date-fns — the only lib that doesn't use the app-standard moment |
- It does not use the shared
apiClient, the raw-JWTAuthorizationheader, or the main AquaGen API. It is rawfetchagainst a separate AI-agent backend, authenticated by asession_idin the request body. - It does not keep history. There is no conversation-history endpoint, and leaving the page logs the AI session out.
- It does not define the agent roster. Agent names arrive from the backend.
- It does not do "predictive analytics" — earlier documentation claiming that was fabricated.
- The
aquagptanddemoAquaGptlibraries are not what you are using; neither is routed anywhere.
"AquaGPT" is the product and route name; the live experience is served by
libs/aquaAi/ (whose header just reads "AI"). Two other AI libs exist and are
not wired to any route — see Variants & legacy.
Which AI library is which
| Library | Status | Where it runs | What it is |
|---|---|---|---|
aquaAi | Current | /aquagpt in all 5 apps | Streaming multi-agent chatbot — this page |
aquagpt | Legacy | Not routed | First-gen, non-streaming chatbot with saved history |
demoAquaGpt | Demo | Not routed | Dummy-data sales prototype |
How a message flows
Summary: One POST opens a stream that carries two interleaved things — the agents' visible reasoning (thought cards) and the answer text. Feedback is only possible after
done, because that event supplies the ids the feedback call needs.
Backend & auth
AquaAi does not use the shared apiClient or the raw-JWT Authorization
header. It calls a dedicated AI-agent service resolved by Urls.getAIAgentBaseUrl():
constants.aiAgentEnv | Base URL |
|---|---|
prod | The Azure-hosted AI-agent backend (.../api) |
local | http://localhost:8000/api |
constants.aiAgentEnv is hardcoded to prod, so dev, demo, and pre-prod builds
all hit the production AI backend. There is also no default branch in the
switch — any other value would make the base URL undefined and every AI request
would fail against a malformed path.
Endpoints
| Endpoint | Method | Purpose |
|---|---|---|
/auth/token-login | POST | Create an AI session — posts { session_id, login_response } |
/auth/validate | POST | Check a cached session; expects valid: true |
/chat/stream | POST | Send a message and stream the run |
/feedback | POST | Submit 👍 / 👎 on an answer |
/auth/logout | POST | End the session — called on unmount |
Every chat and feedback request carries an X-Source-Host header (the current
hostname) so the backend knows which app and industry context the question belongs to.
Session lifecycle
Summary: The AI session is derived from your AquaGen login, cached in
localStorageunderecogpt_session, and revalidated rather than recreated. Leaving the page logs the AI session out, so there is nothing to return to.
Session ids are generated client-side as {userId}_{random} (or
session_{timestamp}_{random} without a user id), using Math.random — not a
cryptographic source.
The SSE event contract
The stream is parsed line by line: only lines beginning data: are considered, and
[DONE] or done terminates it. A line that fails to JSON.parse is logged and
skipped without ending the stream.
All 18 event types and what each one does to the UI
event.type | Effect |
|---|---|
system_start | Adds an "System Starting" activity card |
agent_start | Adds an activity card for event.agent, marks it the current agent |
agent_update | Updates that agent's card in place |
agent_complete | Marks the agent's card completed |
agent_result | Adds the agent's result to its card |
search_query | Adds a search card with the query |
search_result · search | Attaches results to the search card |
insight_found | Adds an insight card |
pattern_detected | Adds a pattern card |
confidence_update | Updates the confidence shown on a card |
synthesis_start | Adds a card attributed to the synthesizer agent |
reasoning | Adds a collapsed reasoning card (event.title, default "Analyzing") |
thought_card | Adds an expanded reasoning card for event.agent |
reflection | Adds a "Reflection" reasoning card |
token | Appends event.content to the answer being streamed |
done | Stops processing, adds a "Complete" card, and stamps the message with runId, conversationId, industryId |
error | Sets the error state and adds an expanded error card |
Two ids are harvested across the stream: run_id from the first event that
carries one, and conversation_id + industry_id from the done event. They are
attached to the finished message purely so feedback can reference them.
The frontend does not define an agent roster — agentName is whatever event.agent
contains, rendered as "Name Agent". The only name hardcoded client-side is
synthesizer, used for synthesis_start. Thought cards are de-duplicated by
agentName + card type.
Feedback payload
| Sent field | From |
|---|---|
conversation_id · industry_id · run_id | The done event's metadata |
is_liked | 👍 / 👎 |
comment | Optional free text |
Components
| Component | What it is | Actions |
|---|---|---|
Composer | Multiline input, hard-capped at 4000 characters | Send / Enter streams the message; the counter appears once you pass 3200 chars or focus the field |
Header | "AI" branding + controls | Stop aborts the stream via AbortController; New Chat resets messages and cards but keeps the session |
MessageList | Groups messages, shows the welcome screen when empty | — |
WelcomeScreen | Example prompts | A prompt card sends that question |
UserBubble | Your message + a relative timestamp | — |
AssistantBubble | The streamed markdown answer — tables, code blocks | Copy, Like, Dislike |
ThoughtCard | Collapsible live agent activity | Expand/collapse; a search chip opens the web search |
Timeline | Orders the cards for a run | — |
Store shape (useReducer)
| State | Purpose |
|---|---|
messages | The conversation, each entry updated in place while streaming |
isProcessing | A run is in flight |
thoughtCards | Live agent activity, each card able to hold a timeoutId that is cleared on removal |
currentAgent | Which agent is active |
abortController | Backs the Stop button |
sessionId · isSessionReady · sessionError | Session state |
error | Last stream error |
Actions: ADD_MESSAGE, UPDATE_MESSAGE, SET_PROCESSING, SET_ERROR,
SET_SESSION_ID, SET_CURRENT_AGENT, ADD_THOUGHT_CARD, UPDATE_THOUGHT_CARD,
REMOVE_THOUGHT_CARD, CLEAR_THOUGHT_CARDS, TOGGLE_THOUGHT_CARD,
SET_ABORT_CONTROLLER, SET_SESSION_READY, SET_SESSION_ERROR, RESET_CHAT
(which deliberately preserves sessionId).
Render states
| State | What the user sees |
|---|---|
| Not logged in to AquaGen | A prompt to log in first — token and userId must both be present |
| Initializing | "Initializing AI…" until the session is ready |
| Empty message | Send stays disabled |
| Over 4000 characters | The input refuses further typing |
| Answering | Thought cards stream in; the answer shows a blinking cursor |
| Stop pressed | The stream aborts silently |
| Stream error | An error message plus an expanded error thought card |
| New Chat | Messages and cards cleared; the session is retained |
| Leaving the page | The AI session is logged out — the conversation is unrecoverable |
Visibility & access
Which apps register the route
/aquagpt → AquaAi in all five apps. Unlike most features this one is
genuinely universal.
Route-level gating: none
The route element is unwrapped; AQUAGPT gates the sidebar entry only.
The alias is defined per app, not centrally
Every other feature lib has its @aquagen-mf-webapp/<lib>/* mapping in the root
tsconfig.base.json. aquaAi does not. Its alias is declared separately in each
app's own tsconfig.json and in that app's rspack.config.js and
rspack.config.prod.js.
Because aquaAi is missing from tsconfig.base.json, a new app that imports
@aquagen-mf-webapp/aquaAi/... will fail to resolve until the mapping is added to its
tsconfig.json and both rspack configs. Moving the entry into tsconfig.base.json
alongside the other libs would remove the trap.
Session gating
Beyond the permission, the composer is unusable until an AI session exists — which
requires loginResponse.token and loginResponse.userId. Subscription expiry is
handled by the global overlay described in Permissions.
Dependencies
Shared store & services
| Import | Used for |
|---|---|
Urls.getAIAgentBaseUrl() | Resolving the AI-agent base URL |
LocalDBInstance · LocalDBKeys | Reading the AquaGen login response for token-login |
constants | aiAgentEnv, env |
Notably absent: apiClient. This feature bypasses the shared HTTP client
entirely and uses raw fetch.
Lib-internal
AquaAiProvider / useAquaAi() (a useReducer store), authService (a singleton
instance), the ChatService class, submitFeedback, useChatStream,
useThoughtCards, useSessionManager.
Browser platform
fetch, ReadableStream + TextDecoder (the SSE parser is hand-rolled),
AbortController (the Stop button), and localStorage directly for the
ecogpt_session cache.
External npm
react-markdown + remark-gfm, react-syntax-highlighter, date-fns (the only
lib not on the app-standard moment), @mui/material. There is no AI SDK.
Usage examples
Read chat state
import { useAquaAi } from '@aquagen-mf-webapp/aquaAi/store/AquaAiStore';
function StreamingIndicator() {
const { state } = useAquaAi(); // throws outside AquaAiProvider
const { messages, isProcessing, thoughtCards, isSessionReady } = state;
if (!isSessionReady) return <span>Initializing AI…</span>;
return isProcessing ? <span>{thoughtCards.length} steps so far</span> : null;
}
Reset the conversation while keeping the session
const { dispatch } = useAquaAi();
dispatch({ type: 'CLEAR_THOUGHT_CARDS' }); // also clears any pending card timeouts
dispatch({ type: 'RESET_CHAT' }); // deliberately preserves sessionId
Stop an in-flight run
const { state } = useAquaAi();
state.abortController?.abort(); // the stream aborts silently, by design
Consume the SSE stream directly
import { ChatService } from '@aquagen-mf-webapp/aquaAi/dataSource/chatService';
const chat = new ChatService();
const response = await chat.sendMessage('Analyse my water balance', sessionId, signal);
for await (const event of chat.parseSSEStream(response)) {
if (event.type === 'token') process.stdout.write(event.content);
if (event.type === 'done') console.log(event.metadata); // runId, conversationId, industryId
}
Establish or reuse a session
import { authService } from '@aquagen-mf-webapp/aquaAi/dataSource/authService';
const session = await authService.ensureSession(); // validates the cache, else token-login
await authService.logout(); // also clears ecogpt_session
Troubleshooting
"Please login to AquaGen first"
hasValidCredentials() requires both token and userId on the stored login
response. A session missing either fails before any AI call is attempted.
Every AI request fails with a malformed URL
getAIAgentBaseUrl() only handles prod and local. Any other value for
constants.aiAgentEnv returns undefined, so requests go to undefined/chat/stream.
Dev traffic is showing up in production AI logs
Expected — constants.aiAgentEnv is hardcoded to prod, so every build talks to the
production AI backend.
The conversation vanished after navigating away
The provider calls authService.logout() on unmount and there is no history API. This
is unrecoverable by design.
Thumbs up/down appears to do nothing
Feedback needs runId, conversationId, and industryId, which only arrive with the
done event — so it cannot work on an aborted or errored run. Failures are logged and
swallowed, with no user-facing signal.
The answer stops mid-sentence with no error
A malformed SSE line is logged and skipped rather than terminating the stream, so
a partial answer can look complete. Check the console for
Error parsing SSE data.
An unfamiliar agent name appears in the thought cards
Agent names come from the backend's event.agent. The frontend hardcodes only
synthesizer.
Variants & legacy
aquagpt — Legacy, orphaned
The first-generation chatbot: a sidebar of past conversations, non-streaming
answers through the shared apiClient (gpt/aqua, gpt/responses/ndays), emoji
reactions, and a typing animation. Not imported by any route — it survives only in
build aliases. Superseded by aquaAi.
demoAquaGpt — Demo, orphaned
A sales prototype built on canned dummy data (dummyData/previousChat.js,
previousMessages.js) that posts an OpenAI-style chat-completions payload to a dev
endpoint. Not routed anywhere — the demo app's /aquagpt renders the live aquaAi
like every other app.
Known issues & gotchas
| Issue | Where | Impact |
|---|---|---|
| All environments use the production AI backend | constants.aiAgentEnv is hardcoded to prod | Dev and demo traffic, including real login responses, reaches production AI |
No default in the base-URL switch | getAIAgentBaseUrl() handles only prod and local, while constants.env defines five values | Any other value yields undefined and every AI call is made against a malformed URL |
| The whole login response is posted to the AI service | /auth/token-login sends login_response verbatim, JWT included | The AquaGen token crosses into a second service |
No Authorization header on chat | Requests authenticate purely by session_id in the body | Possession of a session id is sufficient |
| Weak session ids | Generated with Math.random().toString(36) | Not cryptographically random; the server must be the real guard |
| Leaving the page destroys the conversation | The provider logs out on unmount, and there is no history API | Navigating away and back loses everything with no warning |
| Feedback logs its payload | submitFeedback has leftover console.log calls for the body and the response | Console noise, and the payload is visible in the browser console |
| Feedback fails silently | Non-OK responses are logged and null returned | The user sees no indication their rating did not land |
| Unmount logout is noisy | The provider logs "Logging out on unmount" | Console noise |
| Diverges from the house state pattern | This lib uses useReducer; every other feature lib uses Context + useState | Expect a different mental model when working here |
Code reference
| File | Role |
|---|---|
AquaAi.jsx | Page entry + provider |
store/AquaAiStore.jsx | Reducer, state, unmount logout |
dataSource/authService.js | Session create / validate / logout, ecogpt_session cache |
dataSource/chatService.js | The /chat/stream POST, the SSE parser, feedback |
controller/useChatStream.js | Maps every SSE event onto state |
controller/useThoughtCards.js | Card creation and de-duplication |
controller/useSessionManager.js | Auto session login |
components/ChatWindow.jsx · MessageList.jsx · Timeline.jsx | Layout |
components/Composer.jsx | Input, limits, send |
components/AssistantBubble.jsx · ThoughtCard.jsx | Answer rendering + agent activity |
components/Header.jsx · WelcomeScreen.jsx | Chrome and empty state |
components/LoginModal.jsx | Dead — no longer reachable |
Related
- Dashboard · Alerts & Notifications — the data the assistant is asked about
- Configuration & Security —
constantsand the CSP entry this backend needs - API Call Flow — the shared client this feature deliberately bypasses
- Application Routes · Permissions