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’s git status. make react-install never 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 in static/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

npm run build

npm run build:lib (vite build --mode lib)

Entry point

src/main.tsx, which reads its props from the root element’s attributes, and renders App, which is SmarterChat with the Console

src/index.ts, the package’s public api

Output

smarter/smarter/static/react/@smarter.sh/ui-chat/, with a Vite manifest.json that Django’s template tag reads; build/ in a plain clone

dist/: ES and UMD bundles, ui-chat.css, and type declarations

React

bundled, in a vendor chunk with the other dependencies

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 katex/dist/katex.min.css

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

src/components/SmarterChat/

The chat: state, the api calls’ orchestration, the header, the thread, the message box, and the layout of the chat beside the Console. icons.tsx has the toolbar’s svg icons.

src/components/Console/

The Console: its tabs (enums.ts), the JSON to show for each (data.ts), and the Server Logs tab (ServerLogs.tsx).

src/components/AppTitle/

The title, with its valid and deployed icons.

src/components/ErrorBoundary/

Displays a rendering error instead of an empty page. The chat and the Console each have one.

src/lib/api.ts

Every request to the Smarter api: urls, headers, the configuration, prompts, and the prompt’s Server-Sent Events. The only module that calls fetch.

src/lib/messages.ts

The chat thread: building it from the configuration, turning api messages into displayable ones and back, and rendering markdown to sanitized html.

src/lib/code.ts, math.ts, mermaid.ts

Code blocks (highlight.js), math (KaTeX, as a marked extension) and diagrams (Mermaid).

src/lib/cookie.ts

Reading Django’s CSRF cookie, and the chat session and debug cookies.

src/lib/logStream.ts, ansi.ts

The server log stream (EventSource), and its ANSI colors.

src/lib/layout.ts

The chat’s width, the Console’s visibility and the log wrap setting, remembered in localStorage.

src/lib/enums.ts

Message directions and sender roles.

src/types.ts

The props, and the shapes of the api’s data, as aliases of the generated contract types.

src/contract.gen.ts, contract/, scripts/contract-types.mjs

The api contract: its JSON Schema, copied from the backend by make chat-contract, and the TypeScript types generated from it (npm run contract:types). Never edit them by hand.

src/mocks/

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

  1. SmarterChat builds its cookie descriptors (ChatCookies) and client context (ClientContext) from its props.

  2. Its first effect calls startChat(), which calls fetchConfig(): a POST to <apiUrl>config/, with the session key from the session cookie, if there is one, and X-Smarter-Capabilities: chat-contract-v2. normalizeConfig() converts a version 1 configuration, from an older platform, to version 2.

  3. fetchConfig() saves the returned session_key and debug_mode in their cookies, and returns the ChatConfig.

  4. chatInit() builds the thread: the restored history.chat_history if there is one, else the system prompt, the welcome message and the example prompts.

  5. 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

  1. sendMessage() adds the user’s message to the thread, and shows the typing indicator.

  2. 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’s tool_calls. Smarter’s own messages are never sent back.

  3. fetchPrompt() POSTs {session_key, messages} to config.llmclient.url_llmclient. With streamProgress, it asks for Server-Sent Events, and passes each progress event to onProgress, which adds it to the progress state, displayed as a step in the thread.

  4. The result’s messages (data.messages in version 2, smarter.messages in 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 a smarter_error message.

  5. 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

config

The ChatConfig, or null until it loads. The chat is disabled until then.

configError

Why the configuration couldn’t be loaded.

messages

The thread, as ChatMessage objects. display: false hides one without removing it.

showMetadata

Sandbox mode (true) or production mode. It starts as toggleMetadata.

isTyping, progress

A prompt is running, and its steps so far.

chatCount

Incremented by each new chat, which clears the Console’s server logs.

consoleVisible, chatWidth

The layout, from useConsoleVisible() and useChatWidth() in layout.ts.

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:

  1. messageFactory() turns an api message into a ChatMessage. The user’s messages go through convertMarkdownLinksToHTML(), which escapes them and converts only links and images. Every other role goes through convertMarkdownToHTML().

  2. convertMarkdownToHTML() parses GitHub flavored markdown with marked, with the math extension from math.ts, and custom renderers that escape raw html, restrict link and image urls, and render code blocks with code.ts.

  3. 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.ts for api.ts, Component.test.tsx for Component.tsx.

  • Every story is also a test. src/stories.test.tsx renders each story in *.stories.tsx, and runs its play function, 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.ts holds the api’s data, copied from real responses, and src/mocks/handlers.ts groups handlers by scenario (chatHandlers, promptErrorHandlers, streamingHandlers, …). An unmocked request fails the test.

  • jsdom has no EventSource. installFakes() from src/mocks/fakes.ts installs FakeEventSource, which a test drives with open(), emit() and fail().

  • The fixtures match the contract. src/contract.test.ts validates them against contract/chat-contract.schema.json with 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 mermaid with src/mocks/mermaid.ts. Storybook’s WithDiagram story 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

  1. Add it to SmarterChatProps in src/types.ts, optional, with a doc comment.

  2. Give it a default in SmarterChat’s parameter list. The default must suit the npm package’s users.

  3. If the web console sets it, read it from a root element attribute in src/main.tsx, add the attribute to index.html, and to templates/react/smarter-chat.html and the smarter_chat context of PromptWorkbenchView, in the Smarter repository (see How the Web Console Hosts It).

  4. Add it to the props table of the README, and of Embedding Smarter Chat.

  5. 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):

  1. Declare the field in the backend’s model, in smarter.apps.prompt.contract.models (for example PromptChatApiLLMClient for llmclient.*), optional unless every supported Smarter release sends it. If the public configuration must include it, add it to PUBLIC_LLMCLIENT_FIELDS too.

  2. Run make chat-contract, in the Smarter repository. It regenerates contract/chat-contract.schema.json and src/contract.gen.ts here.

  3. Add the field to makeConfig() in src/mocks/fixtures.ts. src/contract.test.ts checks the fixtures against the schema, and that src/contract.gen.ts is up to date.

  4. Read it, with a fallback for platforms that don’t send it yet, and for version 1 of the contract, which normalizeConfig() in src/lib/api.ts converts.

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.