Feature - AI Theme Change via Chat (SSE Actions)
Feature - AI Theme Change via Chat (SSE Actions)
Hey everyone! Today we are going to understand a very interesting feature - Changing the app's theme through AI chat!
When user types something like "switch to dark mode" or "change color to blue" in the AI chat, the app instantly changes its theme - no settings page needed!
This is where you'll understand how the bot (Python) and frontend (React) communicate to make this happen.
What we will cover:
- The Big Picture - How it works end to end
- What is SSE (Server-Sent Events)?
- Bot Side - The change_theme Tool
- Bot Side - How actions flow through agent.py
- Frontend Side - chat.service.ts reads the action
- Frontend Side - handleAction does the work
- Frontend Side - ThemeProvider catches the event
- Why Custom Event? (The Bridge Problem)
- Complete Code Walkthrough
- Files Changed
The Big Picture
Before we go deep, let's see the full flow from start to end:
The Complete Flow:
==================
User types: "switch to dark mode with blue accent"
|
v
┌─────────────────────────────────────────────────────────┐
│ STEP 1: Frontend sends message to Bot via HTTP POST │
└──────────────────────────┬──────────────────────────────┘
|
v
┌─────────────────────────────────────────────────────────┐
│ STEP 2: Bot AI reads the message │
│ AI thinks: "User wants to change theme!" │
│ AI decides: "I should call change_theme tool" │
└──────────────────────────┬──────────────────────────────┘
|
v
┌─────────────────────────────────────────────────────────┐
│ STEP 3: Bot calls change_theme(theme_mode="dark", │
│ accent_color="blue") │
│ │
│ Tool returns: │
│ { │
│ "success": true, │
│ "actions": [ │
│ {"type": "change_theme", "value": "dark"}, │
│ {"type": "change_accent", "value": "blue"} │
│ ] │
│ } │
└──────────────────────────┬──────────────────────────────┘
|
v
┌─────────────────────────────────────────────────────────┐
│ STEP 4: Bot sends actions as SSE events to frontend │
│ │
│ data: {"action": {"type": "change_theme", "value": "dark"}} │
│ data: {"action": {"type": "change_accent", "value": "blue"}} │
│ data: {"token": "Done! Switched to dark mode..."} │
│ data: [DONE] │
└──────────────────────────┬──────────────────────────────┘
|
v
┌─────────────────────────────────────────────────────────┐
│ STEP 5: Frontend reads the SSE stream │
│ chat.service.ts sees "action" → calls onAction() │
└──────────────────────────┬──────────────────────────────┘
|
v
┌─────────────────────────────────────────────────────────┐
│ STEP 6: handleAction() runs │
│ │
│ "change_theme" → fires custom event "ai-change-theme" │
│ "change_accent" → dispatch(setAccentColor("blue")) │
└──────────────────────────┬──────────────────────────────┘
|
v
┌─────────────────────────────────────────────────────────┐
│ STEP 7: ThemeProvider catches the custom event │
│ → setTheme("dark") │
│ → adds class="dark" to HTML element │
│ → ENTIRE APP GOES DARK INSTANTLY! │
└─────────────────────────────────────────────────────────┘
That's it! Now let's understand each step in detail.
Q: What is SSE (Server-Sent Events)?
A: SSE is how the bot sends its response piece by piece instead of all at once. It's like a live TV broadcast - data keeps coming as it's generated!
WITHOUT SSE (Normal API Call):
==============================
Frontend: "Hey bot, show me today's attendance"
[waits 5 seconds... user sees nothing...]
Bot: "Here are 15 members who checked in today..."
(FULL response arrives at once)
WITH SSE (Streaming):
=====================
Frontend: "Hey bot, show me today's attendance"
Bot: "Here" ← arrives immediately
Bot: " are" ← 50ms later
Bot: " 15" ← 100ms later
Bot: " members" ← 150ms later
Bot: " who" ← 200ms later
Bot: " checked in..." ← 250ms later
Bot: [DONE] ← finished!
User sees text appearing word by word!
Just like ChatGPT!
Your app already uses SSE for the AI chat. The typing effect you see in the chat bubble? That's SSE!
Now, we added a new type of data in the SSE stream - actions!
What the SSE stream looks like now:
====================================
data: {"conversation_id": "abc-123"} ← Track conversation
data: {"tools_used": ["change_theme"]} ← Bot used a tool
data: {"token": "Done!"} ← Text chunk
data: {"token": " Switched"} ← Text chunk
data: {"token": " to dark mode."} ← Text chunk
data: {"action": {"type": "change_theme", "value": "dark"}} ← NEW! Theme action
data: {"action": {"type": "change_accent", "value": "blue"}} ← NEW! Accent action
data: {"suggested_questions": ["...", "..."]} ← Follow-up buttons
data: [DONE] ← Stream finished
The action events are just another piece of data riding on the same stream. The frontend reads them and does something with them!
Bot Side - The change_theme Tool
First, the AI needs to know it can change themes. We give it a tool - a function it can call when it decides to.
File: strakly-bot/tools/theme.py (NEW file)
from langchain_core.tools import tool
@tool
async def change_theme(
theme_mode: str = None,
accent_color: str = None,
) -> dict:
"""Change the app's visual theme or accent color.
Use this when user asks to:
- Switch to dark mode, light mode, or system default
- Change the accent/brand color of the app
Parameters:
- theme_mode: "dark", "light", or "system"
- accent_color: "green", "blue", "purple", "red", "orange",
"teal", "pink", "black", "white"
"""
actions = []
if theme_mode and theme_mode.lower() in ("dark", "light", "system"):
actions.append({"type": "change_theme", "value": theme_mode.lower()})
if accent_color and accent_color.lower() in ("green", "blue", "purple", ...):
actions.append({"type": "change_accent", "value": accent_color.lower()})
return {"success": True, "actions": actions}
Key things to notice:
- @tool decorator - This tells LangChain "hey, the AI can call this function"
- The docstring (""" ... """) - The AI reads this to understand WHEN to use the tool
- It does NOT make any API call - it just returns a dict with "actions"
- The actions are instructions for the frontend - "change theme to dark", "change accent to blue"
Q: Why doesn't this tool call an API?
A: Because theme changes happen on the USER'S BROWSER,
not on the server!
The bot can't change a user's dark mode.
Only the frontend (React) can do that.
So the tool just says: "hey frontend, please do this"
And the frontend does it!
┌──────────┐ actions ┌──────────┐
│ Bot │ ──────────────→ │ Frontend │
│ (Python) │ "change to │ (React) │
│ │ dark mode" │ │
└──────────┘ └──────────┘
│
▼
Theme changes!
Bot Side - How actions flow through agent.py
Now the tool is created. But how does the action get from the tool result to the SSE stream?
File: strakly-bot/agent.py
Step 1: When the AI calls change_theme, the _execute_tool_calls() function runs it:
BEFORE (old code):
==================
async def _execute_tool_calls(tool_calls, messages, conversation):
tools_used = [] ← only returned tool names
for tool_call in tool_calls:
result = await TOOL_MAP[tool_name].ainvoke(tool_args)
tool_result = str(result)
...
return tools_used ← just names like ["change_theme"]
AFTER (new code):
=================
async def _execute_tool_calls(tool_calls, messages, conversation):
tools_used = []
actions = [] ← NEW! collect actions too
for tool_call in tool_calls:
result = await TOOL_MAP[tool_name].ainvoke(tool_args)
# NEW! Check if the tool returned any actions
if isinstance(result, dict) and result.get("actions"):
actions.extend(result["actions"])
tool_result = str(result)
...
return tools_used, actions ← now returns BOTH!
What happens inside:
====================
AI calls: change_theme(theme_mode="dark", accent_color="blue")
│
▼
Tool runs and returns:
{
"success": true,
"actions": [
{"type": "change_theme", "value": "dark"},
{"type": "change_accent", "value": "blue"}
]
}
│
▼
_execute_tool_calls() checks: does result have "actions"?
YES! → collects them into actions list
│
▼
Returns: (["change_theme"], [{"type": "change_theme", ...}, {"type": "change_accent", ...}])
↑ tool names ↑ actions for frontend
Step 2: In process_chat_stream(), the actions are sent as SSE events:
In process_chat_stream():
=========================
# After all tool calls are done and response is streamed...
# Send actions to frontend (NEW!)
for action in all_actions:
yield f"data: {json.dumps({'action': action})}\n\n"
# Then send suggested questions (already existed)
yield f"data: {json.dumps({'suggested_questions': questions})}\n\n"
# Then signal done (already existed)
yield "data: [DONE]\n\n"
What the frontend receives over the wire:
==========================================
data: {"conversation_id": "abc-123"}
data: {"tools_used": ["change_theme"]}
data: {"token": "Done! "}
data: {"token": "Switched "}
data: {"token": "to **dark mode** "}
data: {"token": "with **blue** accent."}
data: {"action": {"type": "change_theme", "value": "dark"}} ← NEW!
data: {"action": {"type": "change_accent", "value": "blue"}} ← NEW!
data: {"suggested_questions": ["Change to light mode", ...]}
data: [DONE]
Frontend Side - chat.service.ts reads the action
Now the data has arrived at the frontend. chat.service.ts is the file that reads the SSE stream.
File: strakly_frontend/src/services/api/chat.service.ts
This file has a function called sendMessageStream(). It takes callback functions as parameters:
sendMessageStream() Parameters:
================================
async sendMessageStream(
data, ← the message to send
onChunk, ← called when a text token arrives
onDone, ← called when stream ends
onError, ← called on error
signal?, ← for cancellation
onAction?, ← NEW! called when an action arrives
)
Think of these callbacks like event handlers:
Analogy - Like addEventListener:
=================================
// When button is clicked → run this function
button.addEventListener("click", handleClick)
// When text token arrives → run onChunk
// When action arrives → run onAction ← NEW!
// When stream ends → run onDone
// When error happens → run onError
Inside the SSE reading loop, we added ONE new check:
BEFORE (old code):
==================
const parsed = JSON.parse(payload)
if (parsed.token) {
onChunk(parsed.token) // handle text
}
if (parsed.conversation_id) {
conversationId = parsed.conversation_id // track conversation
}
if (parsed.suggested_questions) {
suggestedQuestions = parsed.suggested_questions // save questions
}
AFTER (new code - just ONE line added):
=======================================
const parsed = JSON.parse(payload)
if (parsed.token) {
onChunk(parsed.token)
}
if (parsed.conversation_id) {
conversationId = parsed.conversation_id
}
if (parsed.suggested_questions) {
suggestedQuestions = parsed.suggested_questions
}
if (parsed.action && onAction) { // ← NEW!
onAction(parsed.action) // ← NEW!
}
That's it! When an action arrives in the stream, it calls the onAction callback. But who provides this callback?
Frontend Side - handleAction does the work
The callback is provided by useAIChatPage.ts (full page chat) and ai-chat.tsx (floating chat).
File: strakly_frontend/src/pages/AIChat/useAIChatPage.ts
The handleAction function:
===========================
const handleAction = useCallback((action: { type: string; value: string }) => {
// Dark/Light mode change
if (action.type === "change_theme") {
window.dispatchEvent(
new CustomEvent("ai-change-theme", { detail: action.value })
)
// Fires a custom event: "ai-change-theme" with value "dark"
// ThemeProvider will catch this (explained below)
}
// Accent color change
if (action.type === "change_accent") {
dispatch(setAccentColor(action.value as AccentColorKey))
// Directly updates Redux store
// All components using accent color re-render
}
}, [dispatch])
Then we pass it to sendMessageStream:
await chatService.sendMessageStream(
{ message, conversation_id, branch_id },
onChunk, // handles text tokens
onDone, // handles stream end
onError, // handles errors
signal, // for cancellation
handleAction, // ← WE PASS IT HERE!
)
The Connection Chain:
=====================
sendMessageStream receives handleAction as "onAction"
│
▼
SSE stream arrives with: {"action": {"type": "change_theme", "value": "dark"}}
│
▼
chat.service.ts: if (parsed.action && onAction) { onAction(parsed.action) }
│
▼
onAction IS handleAction (we passed it!)
│
▼
handleAction runs:
│
├─→ action.type === "change_theme"
│ → window.dispatchEvent("ai-change-theme")
│
└─→ action.type === "change_accent"
→ dispatch(setAccentColor("blue"))
Q: What is window.dispatchEvent? Why not just call setTheme directly?
A: This is the Bridge Problem! Let me explain...
The Bridge Problem: =================== Your app has TWO separate theme systems: 1. DARK/LIGHT MODE ┌───────────────────────┐ │ ThemeProvider │ ← Lives in React Context │ (Context API) │ Uses: useTheme() hook │ │ Storage: localStorage └───────────────────────┘ 2. ACCENT COLOR (green, blue, red...) ┌───────────────────────┐ │ Redux Store │ ← Lives in Redux │ (themeSlice) │ Uses: dispatch() │ │ Storage: Redux state └───────────────────────┘
Accent color is easy! Redux is global - you can dispatch() from anywhere:
Accent Color - EASY:
====================
// From useAIChatPage.ts:
dispatch(setAccentColor("blue")) ← Works directly! No problem!
// Redux is accessible from any component
// Just import useAppDispatch and dispatch
Dark/Light mode is the problem! It lives in React Context:
Dark/Light Mode - THE PROBLEM:
===============================
// ThemeProvider gives you: useTheme() hook
// But hooks can ONLY be used inside React components!
// useAIChatPage.ts is a custom hook
// handleAction is a callback function INSIDE that hook
// You CANNOT call useTheme().setTheme() from inside a callback!
// WHY?
// useTheme() returns { theme, setTheme }
// But the setTheme from ThemeProvider is in a DIFFERENT part
// of the React tree. They don't know about each other!
React Tree:
===========
App
│
├── ThemeProvider ← setTheme lives HERE
│ │
│ ├── Layout
│ │ │
│ │ ├── AIChat Page
│ │ │ │
│ │ │ └── useAIChatPage.ts ← handleAction lives HERE
│ │ │
│ │ └── Other Pages...
│ │
│ └── Floating Chat
│
└── ...
handleAction needs to talk to ThemeProvider
but they are in DIFFERENT places in the tree!
Solution: Custom Browser Event! It's like a walkie-talkie!
The Walkie-Talkie Solution:
============================
window (browser)
┌──────────────────────┐
│ │
│ Custom Event Bus │
│ "ai-change-theme" │
│ │
└──────┬───────────────┘
│
┌────────────┼────────────┐
│ │ │
SENDER LISTENER LISTENER
│ │ │
handleAction ThemeProvider (anyone else
sends event catches it who wants to
here here listen)
Step 1: handleAction SENDS:
window.dispatchEvent(new CustomEvent("ai-change-theme", { detail: "dark" }))
Step 2: ThemeProvider CATCHES:
window.addEventListener("ai-change-theme", (e) => {
setTheme(e.detail) // "dark"
})
This is exactly like how your app already works for other things:
Your app already uses custom events:
=====================================
// Toggle floating chat with keyboard shortcut
window.dispatchEvent(new Event("toggle-ai-chat"))
// Load conversation in floating chat
window.dispatchEvent(new CustomEvent("load-floating-chat", { detail: {...} }))
// NEW: Change theme from AI chat
window.dispatchEvent(new CustomEvent("ai-change-theme", { detail: "dark" }))
Same pattern! Nothing new!
Frontend Side - ThemeProvider catches the event
File: strakly_frontend/src/providers/ThemeProvider/index.tsx
We added one useEffect that listens for the custom event:
// Inside ThemeProvider component:
// This already existed - applies theme to HTML element
useEffect(() => {
const root = document.documentElement // <html> element
root.classList.remove("light", "dark")
root.classList.add(theme) // adds "dark" or "light"
}, [theme])
// NEW! Listen for AI-triggered theme changes
useEffect(() => {
const handler = (e: Event) => {
const newTheme = (e as CustomEvent).detail // "dark"
// Save to localStorage (persists on page refresh)
localStorage.setItem("goalapp-theme", newTheme)
// Update React state
setTheme(newTheme)
// This triggers the useEffect above!
// Which adds class="dark" to <html>!
// Tailwind dark: classes activate!
// ENTIRE APP GOES DARK!
}
window.addEventListener("ai-change-theme", handler)
return () => window.removeEventListener("ai-change-theme", handler)
}, [storageKey])
What happens when setTheme("dark") is called:
==============================================
setTheme("dark")
│
▼
React state updates: theme = "dark"
│
▼
useEffect runs (because theme changed)
│
▼
document.documentElement.classList.remove("light", "dark")
document.documentElement.classList.add("dark")
│
▼
<html class="dark">
│
▼
Tailwind CSS sees class="dark" on <html>
│
▼
All dark: classes activate:
- dark:bg-gray-900 → background goes dark
- dark:text-white → text goes white
- dark:border-gray-700 → borders change
│
▼
USER SEES: Entire app switches to dark mode instantly!
Bot Side - System Prompt Update
One more thing! We need to tell the AI when to use this tool. This is done in the system prompt.
File: strakly-bot/prompts/system_prompt.py
Added to system prompt: ======================== ## Theme / Appearance Users can ask you to change the app's appearance: - **Dark/Light mode**: "switch to dark mode", "make it light" → Call change_theme with theme_mode="dark", "light", or "system" - **Accent color**: "change color to blue", "make it purple" → Call change_theme with accent_color (green, blue, purple, red, etc.) - Both at once: "dark mode with blue accent" → Call change_theme with both theme_mode AND accent_color After changing, confirm: "Done! Switched to **dark mode** with **blue** accent."
Without this instruction, the AI wouldn't know it can change themes. The system prompt is like a job description for the AI!
Complete Timeline - What Happens Step by Step
Timeline when user types "dark mode with blue accent":
=======================================================
TIME WHAT HAPPENS WHERE
──── ────────────────────────────────────────── ─────────────
0ms User types message and hits Send Frontend
│
50ms Frontend sends POST to bot API chat.service.ts
│
100ms Bot receives message agent.py
│
200ms AI reads message + system prompt agent.py
AI thinks: "User wants theme change"
AI decides: "I'll call change_theme tool"
│
300ms change_theme(theme_mode="dark", tools/theme.py
accent_color="blue") runs
Returns: {actions: [{...}, {...}]}
│
400ms _execute_tool_calls collects actions agent.py
│
500ms AI generates response text agent.py
Starts streaming tokens via SSE
│
500ms "Done!" arrives at frontend chat.service.ts
600ms " Switched" arrives chat.service.ts
700ms " to dark mode" arrives chat.service.ts
│
Text appears word by word in chat bubble!
│
800ms {"action": {"type": "change_theme", chat.service.ts
"value": "dark"}} arrives
│
chat.service.ts calls onAction()
│
800ms handleAction fires! useAIChatPage.ts
│
├→ window.dispatchEvent("ai-change-theme")
│ │
│ └→ ThemeProvider catches it ThemeProvider
│ setTheme("dark")
│ <html class="dark">
│ APP GOES DARK! ← user sees it!
│
850ms {"action": {"type": "change_accent", chat.service.ts
"value": "blue"}} arrives
│
handleAction fires!
dispatch(setAccentColor("blue")) useAIChatPage.ts
Redux updates → accent color changes!
│
900ms suggested_questions arrive chat.service.ts
│
950ms [DONE] → stream finished chat.service.ts
│
onDone() called → save conversation to DB
TOTAL TIME: ~1 second!
Quick Recap - All Files Changed
| File | What Changed | Why |
|---|---|---|
| tools/theme.py | NEW file - change_theme tool | So AI knows it can change themes |
| tools/__init__.py | Registered change_theme in ALL_TOOLS | So LangChain includes it when calling AI |
| agent.py | Collects actions from tools, sends as SSE | So actions reach the frontend |
| system_prompt.py | Added theme change instructions | So AI knows WHEN to use the tool |
| chat.service.ts | Added onAction callback, parses action events | So frontend can receive actions from SSE |
| useAIChatPage.ts | Added handleAction function | Handles actions for full page chat |
| ai-chat.tsx | Added handleAction function | Handles actions for floating chat |
| ThemeProvider/index.tsx | Added event listener for "ai-change-theme" | So dark/light mode actually changes |
Quick Recap - Concepts
| Concept | Description |
|---|---|
| SSE | Server-Sent Events - bot sends data piece by piece (streaming) |
| LangChain Tool | A function the AI can call when it decides to (like change_theme) |
| Action Event | New SSE event type that tells frontend to DO something |
| Custom Event | Browser event (walkie-talkie) to communicate between React components |
| React Context | Where dark/light mode lives (ThemeProvider) |
| Redux | Where accent color lives (themeSlice) |
| onAction callback | Function passed to sendMessageStream to handle action events |
Interview Questions
Q: How does the AI chat change the app's theme?
"The bot has a change_theme LangChain tool. When the AI decides to use it, the tool returns action objects. The bot sends these as SSE events to the frontend. The frontend's chat service reads the action, and the handleAction callback dispatches theme changes - accent color via Redux, dark/light mode via a custom browser event that the ThemeProvider catches."
Q: Why use a custom browser event instead of directly calling setTheme?
"Dark/light mode lives in React Context (ThemeProvider), which is in a different part of the React tree than the chat hook. You can't call useTheme() from inside a callback. So we use window.dispatchEvent as a bridge - the chat fires the event, and the ThemeProvider listens for it. It's like a walkie-talkie between two parts of the app."
Q: Why is accent color handled differently from dark/light mode?
"Accent color is stored in Redux, which is globally accessible. You can dispatch(setAccentColor('blue')) from anywhere. But dark/light mode is stored in React Context, which requires the useTheme() hook. Since hooks can't be called inside callbacks, we use a custom DOM event as a bridge to the ThemeProvider."
Q: What is SSE and how is it used here?
"SSE (Server-Sent Events) is a protocol where the server sends data to the client as a stream of events. The bot uses SSE to stream text tokens (for the typing effect), conversation metadata, suggested questions, and now action events. The frontend reads the stream using fetch + ReadableStream and processes each event type with different callbacks."
Q: What happens if the user asks to change theme but the bot fails?
"If the change_theme tool receives invalid parameters, it returns success: false with an error message. The AI then tells the user what went wrong. No action events are sent, so the frontend doesn't change anything. The app stays as it was."
Key Points to Remember
- change_theme tool doesn't call any API - it just returns action instructions for the frontend
- Actions ride on the same SSE stream as text tokens - no separate connection needed
- chat.service.ts parses action events and calls onAction callback
- handleAction exists in BOTH useAIChatPage.ts AND ai-chat.tsx (full page + floating chat)
- Accent color → Redux dispatch (direct, easy)
- Dark/Light mode → Custom event → ThemeProvider (needs a bridge)
- The custom event pattern is already used in the app for other things (toggle-ai-chat, load-floating-chat)
- Theme changes happen during streaming - user sees the change before the bot finishes typing
- The system prompt tells the AI when to use the tool and how to respond after
- This pattern can be extended for other frontend actions (navigate to a page, open a dialog, etc.)
What's Next?
This "action event" pattern is very powerful! You can extend it for:
- Navigation - "take me to clients page" → action: navigate("/clients")
- Open dialogs - "create a new client" → action: open create client dialog
- Play sounds - "play notification" → action: play sound
- Glass theme (future) - "enable glass theme" → action: toggle glassmorphism
The bot sends the action, the frontend executes it. Simple and extensible!
Keep coding, keep learning! See you in the next one!
Post a Comment