Skip to main content

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)
PermissionAQUAGPT gates the sidebar entry; the route itself is not wrapped
Librarylibs/aquaAi/ — the live implementation
BackendA separate AI-agent service, not the main AquaGen API
Transportfetch + Server-Sent Events; no AI SDK
AuthA session_id in the request bodynot the raw-JWT apiClient
StateuseReducer — the one feature lib that does not use the house Context + useState pattern
HistoryNone — no conversation-history API
Datesdate-fns — the only lib that doesn't use the app-standard moment
What AquaGPT does NOT do
  • It does not use the shared apiClient, the raw-JWT Authorization header, or the main AquaGen API. It is raw fetch against a separate AI-agent backend, authenticated by a session_id in 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 aquagpt and demoAquaGpt libraries are not what you are using; neither is routed anywhere.
One feature, three libraries

"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

LibraryStatusWhere it runsWhat it is
aquaAiCurrent/aquagpt in all 5 appsStreaming multi-agent chatbot — this page
aquagptLegacyNot routedFirst-gen, non-streaming chatbot with saved history
demoAquaGptDemoNot routedDummy-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.aiAgentEnvBase URL
prodThe Azure-hosted AI-agent backend (.../api)
localhttp://localhost:8000/api
Every environment talks to production AI

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

EndpointMethodPurpose
/auth/token-loginPOSTCreate an AI session — posts { session_id, login_response }
/auth/validatePOSTCheck a cached session; expects valid: true
/chat/streamPOSTSend a message and stream the run
/feedbackPOSTSubmit 👍 / 👎 on an answer
/auth/logoutPOSTEnd 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 localStorage under ecogpt_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.typeEffect
system_startAdds an "System Starting" activity card
agent_startAdds an activity card for event.agent, marks it the current agent
agent_updateUpdates that agent's card in place
agent_completeMarks the agent's card completed
agent_resultAdds the agent's result to its card
search_queryAdds a search card with the query
search_result · searchAttaches results to the search card
insight_foundAdds an insight card
pattern_detectedAdds a pattern card
confidence_updateUpdates the confidence shown on a card
synthesis_startAdds a card attributed to the synthesizer agent
reasoningAdds a collapsed reasoning card (event.title, default "Analyzing")
thought_cardAdds an expanded reasoning card for event.agent
reflectionAdds a "Reflection" reasoning card
tokenAppends event.content to the answer being streamed
doneStops processing, adds a "Complete" card, and stamps the message with runId, conversationId, industryId
errorSets 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.

Agent names come from the backend

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 fieldFrom
conversation_id · industry_id · run_idThe done event's metadata
is_liked👍 / 👎
commentOptional free text

Components

ComponentWhat it isActions
ComposerMultiline input, hard-capped at 4000 charactersSend / Enter streams the message; the counter appears once you pass 3200 chars or focus the field
Header"AI" branding + controlsStop aborts the stream via AbortController; New Chat resets messages and cards but keeps the session
MessageListGroups messages, shows the welcome screen when empty
WelcomeScreenExample promptsA prompt card sends that question
UserBubbleYour message + a relative timestamp
AssistantBubbleThe streamed markdown answer — tables, code blocksCopy, Like, Dislike
ThoughtCardCollapsible live agent activityExpand/collapse; a search chip opens the web search
TimelineOrders the cards for a run
Store shape (useReducer)
StatePurpose
messagesThe conversation, each entry updated in place while streaming
isProcessingA run is in flight
thoughtCardsLive agent activity, each card able to hold a timeoutId that is cleared on removal
currentAgentWhich agent is active
abortControllerBacks the Stop button
sessionId · isSessionReady · sessionErrorSession state
errorLast 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

StateWhat the user sees
Not logged in to AquaGenA prompt to log in first — token and userId must both be present
Initializing"Initializing AI…" until the session is ready
Empty messageSend stays disabled
Over 4000 charactersThe input refuses further typing
AnsweringThought cards stream in; the answer shows a blinking cursor
Stop pressedThe stream aborts silently
Stream errorAn error message plus an expanded error thought card
New ChatMessages and cards cleared; the session is retained
Leaving the pageThe AI session is logged out — the conversation is unrecoverable

Visibility & access

Which apps register the route

/aquagptAquaAi 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.

Adding a new app means adding the alias three times

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

ImportUsed for
Urls.getAIAgentBaseUrl()Resolving the AI-agent base URL
LocalDBInstance · LocalDBKeysReading the AquaGen login response for token-login
constantsaiAgentEnv, 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

aquagptLegacy, 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.

demoAquaGptDemo, 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

Verified against the code
IssueWhereImpact
All environments use the production AI backendconstants.aiAgentEnv is hardcoded to prodDev and demo traffic, including real login responses, reaches production AI
No default in the base-URL switchgetAIAgentBaseUrl() handles only prod and local, while constants.env defines five valuesAny 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 includedThe AquaGen token crosses into a second service
No Authorization header on chatRequests authenticate purely by session_id in the bodyPossession of a session id is sufficient
Weak session idsGenerated with Math.random().toString(36)Not cryptographically random; the server must be the real guard
Leaving the page destroys the conversationThe provider logs out on unmount, and there is no history APINavigating away and back loses everything with no warning
Feedback logs its payloadsubmitFeedback has leftover console.log calls for the body and the responseConsole noise, and the payload is visible in the browser console
Feedback fails silentlyNon-OK responses are logged and null returnedThe user sees no indication their rating did not land
Unmount logout is noisyThe provider logs "Logging out on unmount"Console noise
Diverges from the house state patternThis lib uses useReducer; every other feature lib uses Context + useStateExpect a different mental model when working here

Code reference

FileRole
AquaAi.jsxPage entry + provider
store/AquaAiStore.jsxReducer, state, unmount logout
dataSource/authService.jsSession create / validate / logout, ecogpt_session cache
dataSource/chatService.jsThe /chat/stream POST, the SSE parser, feedback
controller/useChatStream.jsMaps every SSE event onto state
controller/useThoughtCards.jsCard creation and de-duplication
controller/useSessionManager.jsAuto session login
components/ChatWindow.jsx · MessageList.jsx · Timeline.jsxLayout
components/Composer.jsxInput, limits, send
components/AssistantBubble.jsx · ThoughtCard.jsxAnswer rendering + agent activity
components/Header.jsx · WelcomeScreen.jsxChrome and empty state
components/LoginModal.jsxDead — no longer reachable