Developing Smarter Chat
This guide is for developers who write React code in Smarter Chat, whether for the web console’s prompt engineering workbench or for the npm package. It assumes you know React and TypeScript, and explains how Smarter Chat is put together, how to run it, how to test it, and how to make the most common kinds of change. For the conventions that it shares with the web console’s other React apps, see Django-React Integration.
Where the Code Lives
Smarter Chat is managed in its own repository,
smarter-sh/smarter-chat, where it is versioned and
published to npm. Inside the Smarter repository, make react-install clones it into the React
workspace, as smarter/react/packages/smarter-chat, which Smarter’s .gitignore excludes:
make react-install # clones smarter-chat's main branch, if it isn't there yet
make react-install SMARTER_CHAT_BRANCH=alpha # or another branch
From then on it is a workspace package like any other, with the same TypeScript, Vite, Vitest, Storybook, ESLint and Prettier configuration as the other React apps. Two things are different:
It is its own git repository. Commit, branch and push from inside
smarter/react/packages/smarter-chat. Nothing you do there shows up in Smarter’sgit status.make react-installnever changes an existing clone, so work in progress in it is safe.Its app name is
@smarter.sh/ui-chat, its npm package name, rather than the@smarter/<name>of the other apps. Its build is instatic/react/@smarter.sh/ui-chat/.
The GitHub Actions workflows of the Smarter repository clone Smarter Chat before the React build
(.github/actions/react-build, whose smarter-chat-branch input defaults to main), so a
change merged into that branch ships with Smarter’s next image build. The cache key of the React
build includes the clone’s commit.
You can also work on it alone, without Smarter:
git clone https://github.com/smarter-sh/smarter-chat.git
cd smarter-chat
make init
Running It
There are three ways to run Smarter Chat, from the fastest feedback to the most realistic.
Storybook, with the api mocked. No backend is needed. The stories’ api responses come from
MSW handlers in src/mocks/. When the Smarter dev server is running on
port 9357, the stories use the web console’s stylesheets, so they look as they do in the web
console; otherwise they use Bootstrap, which the web console’s theme is built on.
make react-storybook APP=smarter-chat # from the Smarter repository, or
make serve # from smarter/react/packages/smarter-chat
Vite’s dev server, against your local Smarter. make run, in the package, serves
index.html with hot module replacement, and proxies /api, /workbench and /static
requests to the Django dev server at http://localhost:9357. Log in to the web console first, so that
the session and CSRF cookies exist, and set smarter-llmclient-api-url in index.html’s root
element to the workbench url of one of your LLMClients, for example
http://localhost:9357/workbench/llm-clients/<hashed_id>/.
The web console itself. make react-build, in the Smarter repository, builds every React app
into Django’s static files, collects them, and builds the Docker image. Then restart
smarter-app, and open an LLMClient’s workbench. Use make react-build, not make build,
which reuses the static files that were collected before. A page whose React app is missing its
build renders an empty root element, with no console errors, and the template tag caches the
missing manifest until smarter-app restarts.
Two Builds From One Source
vite.config.ts builds Smarter Chat two ways:
The web console app |
The npm library |
|
|---|---|---|
Command |
|
|
Entry point |
|
|
Output |
|
|
React |
bundled, in a |
a peer dependency, not bundled |
Mermaid |
its own chunks, downloaded when a message first has a diagram |
left to the consuming page’s bundler |
KaTeX css and fonts |
bundled |
left out; pages import |
Both builds strip console.debug calls, so use console.debug freely for development logging,
and never for anything a production user needs.
src/index.ts is the package’s public api. Anything exported from it is a promise to the
package’s users: removing or renaming an export, or a prop, is a breaking change (see
Releases).
Architecture
Source layout
Path |
Responsibility |
|---|---|
|
The chat: state, the api calls’ orchestration, the header, the thread, the message box, and
the layout of the chat beside the Console. |
|
The Console: its tabs ( |
|
The title, with its valid and deployed icons. |
|
Displays a rendering error instead of an empty page. The chat and the Console each have one. |
|
Every request to the Smarter api: urls, headers, the configuration, prompts, and the
prompt’s Server-Sent Events. The only module that calls |
|
The chat thread: building it from the configuration, turning api messages into displayable ones and back, and rendering markdown to sanitized html. |
|
Code blocks (highlight.js), math (KaTeX, as a |
|
Reading Django’s CSRF cookie, and the chat session and debug cookies. |
|
The server log stream ( |
|
The chat’s width, the Console’s visibility and the log wrap setting, remembered in
|
|
Message directions and sender roles. |
|
The props, and the shapes of the api’s data, as aliases of the generated contract types. |
|
The api contract: its JSON Schema, copied from the backend by |
|
Api fixtures and MSW handlers for the stories and tests, and test doubles. |
Components import from @/, an alias of src/.
What happens when the chat loads
SmarterChatbuilds its cookie descriptors (ChatCookies) and client context (ClientContext) from its props.Its first effect calls
startChat(), which callsfetchConfig(): a POST to<apiUrl>config/, with the session key from the session cookie, if there is one, andX-Smarter-Capabilities: chat-contract-v2.normalizeConfig()converts a version 1 configuration, from an older platform, to version 2.fetchConfig()saves the returnedsession_keyanddebug_modein their cookies, and returns theChatConfig.chatInit()builds the thread: the restoredhistory.chat_historyif there is one, else the system prompt, the welcome message and the example prompts.The header, placeholder and attach button are rendered from
config.llmclient, and the Console from the whole configuration.
A failure sets configError, which the thread and header display.
What happens when the user sends a message
sendMessage()adds the user’s message to the thread, and shows the typing indicator.chatMessages2RequestMessages()turns the thread into api messages: only the roles the LLM accepts (system,assistant,user,tool), each with its original fields, such as an assistant’stool_calls. Smarter’s own messages are never sent back.fetchPrompt()POSTs{session_key, messages}toconfig.llmclient.url_llmclient. WithstreamProgress, it asks for Server-Sent Events, and passes eachprogressevent toonProgress, which adds it to theprogressstate, displayed as a step in the thread.The result’s messages (
data.messagesin version 2,smarter.messagesin the completion in version 1) are added to the thread. Backend messages are hidden if the chat is in production mode. A failed prompt resolves too, with asmarter_errormessage.refreshConfig()fetches the configuration again, so that the Console shows the session’s new history.
See The Chat API Contract for the requests and responses themselves.
State
SmarterChat holds all of the chat’s state, with plain useState; there is no store.
State |
Meaning |
|---|---|
|
The |
|
Why the configuration couldn’t be loaded. |
|
The thread, as |
|
Sandbox mode ( |
|
A prompt is running, and its steps so far. |
|
Incremented by each new chat, which clears the Console’s server logs. |
|
The layout, from |
Rendering messages
Messages are displayed by chatscope’s
Message component, which takes html strings, not React elements. So message rendering is a
pipeline of strings, in messages.ts:
messageFactory()turns an api message into aChatMessage. The user’s messages go throughconvertMarkdownLinksToHTML(), which escapes them and converts only links and images. Every other role goes throughconvertMarkdownToHTML().convertMarkdownToHTML()parses GitHub flavored markdown withmarked, with the math extension frommath.ts, and custom renderers that escape raw html, restrict link and image urls, and render code blocks withcode.ts.The html is sanitized with DOMPurify, and only then displayed.
Because messages are html strings, their interactive parts can’t have React event handlers. The
code blocks’ Copy buttons and the diagrams’ Code buttons are handled by one click listener on
the chat, by event delegation (copyCodeBlock() and toggleMermaidDiagram()). Mermaid renders
asynchronously, so a mermaid block is first rendered as a code block, and
renderMermaidDiagrams(), called in a layout effect whenever the messages change, replaces it in
the page with its diagram. Diagrams are cached by their source, so a re-render doesn’t redraw them.
Important
Every string that reaches a Message must come from messageFactory(), or be sanitized the
same way. Messages come from the LLM, from tools, and from users, and must never be trusted as
html.
Styling
Each component imports its own styles.css. Class names are prefixed smarter-chat- (or
console- in the Console), because the npm package’s css shares the page with its host’s.
chatscope’s classes (cs-*) are overridden in SmarterChat/styles.css. The Console’s markup
uses the web console’s Bootstrap utility classes, which the npm package’s users don’t have; it is a
developer tool, and is styled well enough by its own css.
Testing
Smarter Chat’s tests use Vitest and React Testing Library, with jsdom.
make test # in smarter/react/packages/smarter-chat
make coverage # with a coverage report in coverage/
make lint # ESLint, TypeScript and Prettier
make react-test # from the Smarter repository: every React app, as CI does
make react-lint
One test module per source module, beside it:
api.test.tsforapi.ts,Component.test.tsxforComponent.tsx.Every story is also a test.
src/stories.test.tsxrenders each story in*.stories.tsx, and runs itsplayfunction, with its MSW handlers. A new visual state needs a story; a story that breaks fails the tests.The api is mocked with MSW, in tests and in Storybook alike.
src/mocks/fixtures.tsholds the api’s data, copied from real responses, andsrc/mocks/handlers.tsgroups handlers by scenario (chatHandlers,promptErrorHandlers,streamingHandlers, …). An unmocked request fails the test.jsdom has no
EventSource.installFakes()fromsrc/mocks/fakes.tsinstallsFakeEventSource, which a test drives withopen(),emit()andfail().The fixtures match the contract.
src/contract.test.tsvalidates them againstcontract/chat-contract.schema.jsonwith Ajv, in both versions of the contract, so a test can’t pass against a response that the backend never sends.jsdom can’t lay out Mermaid’s svg, so tests mock
mermaidwithsrc/mocks/mermaid.ts. Storybook’sWithDiagramstory uses the real Mermaid.
See the smarter-react-testing skill in the Smarter repository for the workspace’s shared test
configuration and its pitfalls.
Common Changes
Adding a prop
Add it to
SmarterChatPropsinsrc/types.ts, optional, with a doc comment.Give it a default in
SmarterChat’s parameter list. The default must suit the npm package’s users.If the web console sets it, read it from a root element attribute in
src/main.tsx, add the attribute toindex.html, and totemplates/react/smarter-chat.htmland thesmarter_chatcontext ofPromptWorkbenchView, in the Smarter repository (see How the Web Console Hosts It).Add it to the props table of the README, and of Embedding Smarter Chat.
Test it, and add a story if it changes what the chat looks like.
Using a new field of the configuration
The api’s types are not written by hand. They are generated from the contract’s JSON Schema, which the backend generates from its Pydantic models (see The Chat API Contract):
Declare the field in the backend’s model, in
smarter.apps.prompt.contract.models(for examplePromptChatApiLLMClientforllmclient.*), optional unless every supported Smarter release sends it. If the public configuration must include it, add it toPUBLIC_LLMCLIENT_FIELDStoo.Run
make chat-contract, in the Smarter repository. It regeneratescontract/chat-contract.schema.jsonandsrc/contract.gen.tshere.Add the field to
makeConfig()insrc/mocks/fixtures.ts.src/contract.test.tschecks the fixtures against the schema, and thatsrc/contract.gen.tsis up to date.Read it, with a fallback for platforms that don’t send it yet, and for version 1 of the contract, which
normalizeConfig()insrc/lib/api.tsconverts.
Handling a new kind of progress event
Nothing is required: the chat displays any progress event’s message. To treat a type
specially, switch on event.type in SmarterChat’s onProgress, and add an example to
progressEvents in src/mocks/fixtures.ts. The types are listed in
PromptProgressEvents.
Adding a Console tab
Add its id to MenuItems in Console/enums.ts, its label to MENU in
Console/Component.tsx, and its data to consoleData() in Console/data.ts, which reads it
from the configuration. MenuItems is exported by the package, so adding to it is a feature, and
removing from it is a breaking change.
Extending markdown
Add a marked extension or renderer in messages.ts, following math.ts. Anything it emits
passes through DOMPurify; if it emits tags or attributes that DOMPurify removes, allow exactly those
in sanitize(), never more. Add a fixture with an example to src/mocks/fixtures.ts, and a
story.
Releases
Commit messages follow Conventional Commits, and
semantic-release versions the package from them on each
push to alpha (prereleases) and main. feat is a minor release, fix and perf are
patches, and a BREAKING CHANGE: footer is a major release. After a release on main, publish
it to npm with make release. The package’s .github/CONTRIBUTING.md has the details.
Keep two audiences in mind when you change behavior that depends on the backend:
The web console always runs the Smarter Chat that was cloned when its image was built, so it and the backend change together.
Pages that embed the npm package run whichever version they installed, against whichever Smarter platform they call. A new version of the chat must keep working with the platform releases that are in use, and the platform must keep working with the chat versions that are in use. Read the fields you depend on defensively, and say in the release notes when a feature needs a newer platform.