@djangocfg/widget-assistant
The assistant chat for a site: the SSE wire and session, the persistent host, the docked column, the floating button and the phone sheet, on top of @djangocfg/widget-chat.
It knows nothing about one site (src/boundary.test.ts enforces it), so what a host passes stays small: a language (from the framework's locale provider), an adapter, suggestions and its own part renderers. Source-only: the host transpiles it (DEFAULT_TRANSPILE_PACKAGES) and imports @djangocfg/widget-assistant/styles once.
<AssistantChat adapter={adapter} suggestions={suggestions} dataRenderers={renderers} />
Docs
Longer pages live in @docs/ (repository only, not in the npm package): architecture, the wire contract, the Python server half, connecting a new project, customization, troubleshooting. The server half is not in django-cfg's config: each product writes its own on the agent plane (@docs/server.md).
Folders (one job each)
| Folder | Job |
|---|---|
wire/ | the HTTP and SSE contract: frames, parser, transport, stored conversation |
stream/ | one conversation: adapter, session hook, reconcile after a lost stream, the scripted transport for stories |
composer/ | what the widget's composer lacks, mounted inside it (ComposerBehaviour): dictation keys, caret, height, the Help list |
messages/ | the rows around messages: copy footer, the reconnecting/stalled line |
shell/ | where the chat lives: column frame, dock/sheet, floating button with its hint, locked view, open/width state |
i18n/ | the assistant's own words, 17 languages bundled (locales/), useAssistantCopy(); the language comes from @djangocfg/i18n's provider, the same one the widget's own copy follows |
styles/ | widget stylesheet entry, layout, composer and button motion |
Host.tsx (exported as ./host) keeps one mounted chat for many surfaces; AssistantChat.tsx composes the widget.
Behaviour worth knowing
- Dictation keys.
⌘D/Ctrl+Dstarts and stops (formatHotkeydraws the right one for the system). While dictating, Enter stops and sends, Escape stops and keeps the text, from anywhere. Every stop waits with the microphone open until the field stops changing (450 ms of quiet, 2.5 s at most): the engine reports words late, and the widget's stop closes it and drops them. The engine is our own (composer/wholeTranscript.ts): it rebuilds the whole transcript from the browser's results at every event and restarts itself when the browser ends a run, because the widget's stitching of separate results kept only the last words. - Field. While dictating its height only grows (no jumping between bursts). Pressing the empty strip around the composer focuses the field (keyboard devices only).
- Caret. Opening the panel on a keyboard device focuses the field; after a send the caret returns to the field, or to the microphone when the send ended a dictation (Enter then starts the next one). Never on phones.
- Floating button. Pops in, shows a message-style hint after a moment (
hintprop; text defaults to the bundled one), then a ring every so often. No close control; the host decides how often to show it. - Help. A round
?in the panel header (AssistantHelp, shown by the frame and the dock) opens the list of keys in a popover; hidden on touch devices. - Time. UTC on the wire, the reader's zone and locale on screen. The time and copy row under a message appears on hover; the newest message keeps it in view, but not while its answer is still streaming and never for an empty row.
The wire (0.2)
One vocabulary, data: {json with type}\n\n, decoded only in wire/frames.ts:
| Frame | Fields |
|---|---|
run | version, thread_id, message_id, client_id, seq |
status | phase: thinking | searching | answering |
text_delta | delta |
reasoning_delta | delta (optional layer: the model's thinking, drawn by the widget's own Reasoning block) |
reasoning_end | elapsed_ms?, tokens? (optional: seals the thought; otherwise the next text, payload or done does) |
ui_payload | kind, data: the host draws it through dataRenderers[kind]; list the kinds in payloadKinds and any other is dropped and reported to onUnknownPayload |
done | tokens_in, tokens_out, cost_usd, finish_reason, replayed? |
error | code (unavailable | timeout | failed), message, retryable |
An unknown type is ignored. wire/fixtures/events.json holds one of each frame and is committed on the server side too: drift fails a test on either side. A public chat must not forward tool_call / tool_result.
createTransport takes streamUrl (a path, or a function of the send, for a thread id in the path), buildBody, headers, streamFetch, getToken (signed in) or visitorToken (anonymous, sent as x-visitor-token). Omit api from useSession for an anonymous host: nothing is fetched and the room starts empty. context is a plain record the host defines; the package never reads it. examples/KbAssistant.tsx is a knowledge-base host with a citations payload, the reference the tests mount.
Sessions (0.3)
A session is one thread on the server. useSession({ transport, api, storage, getContext }) shows one at a time and gives the host startNew(), switchTo(id), sessions / refreshSessions() and busy (a turn is streaming: starting or switching waits, Stop ends it).
api: ThreadApiis three calls the host maps to its endpoints:list(signal)(newest activity first),create()(POST threads/, always a new row) andload(id, signal)(throwsThreadNotFoundErroron a 404: a stranger's or purged session).- On load the remembered session (
storage: { read, write }, the host picks the key; reads and writes must not throw) is shown, else the newest, else a blank page. A session is created on the first send, never on load; the transcript is not reseeded then, so the live message survives. createTransport({ streamUrl: ({ threadId }) => … })posts to the session in the path;SendInput.threadIdis whatthreadForreturned.- The history is a screen of its own, drawn over the conversation (Messages, Notes), not a dropdown that grows with the list: Today / Yesterday / one heading per month, a search field from seven chats, the open chat marked, a click opens it and returns. Mount
<AssistantHistory session={session} />besideAssistantPortal(insideAssistantHostProvider) and open it from any control withuseAssistantHistory().setOpen(true);<SessionsMenu session={session} />is the ready-made pair of header buttons (New chat, History / Back to the chat). A host with its own "..." menu (UI CoreMenuBuilder, passed asSessionsMenu'schildrenor inactions) keeps "New chat" there and replaces the list of chats with one "History" row callingsetOpen(true). The chat stays mounted under the screen, so a run keeps streaming. Words:newChat,sessions,sessionsEmpty,sessionsSearch,sessionsNoMatch,sessionsBack; the headings come fromIntl. <AssistantChat draftRoom={session.thread.session ?? 'new'} />keeps each session's unsent draft under its own id.- There is no delete and no
clear(): "clear" is "new chat". Retention is the server's job. - Without
api(anonymous host) nothing is listed;startNew()just empties the room.
Voice (0.3.3)
The microphone menu always offers a dictation language: a visitor may speak any language, whatever the host UI says. With no speech prop the assistant owns it through useSpeechLanguage from @djangocfg/ui-core/lib/speech-language (the last pick, shared by every chat and tab on this device under cfg.chat.speech_language; else the first of the browser's languages dictation supports, the page language only breaking a tie; else English) and feeds createWholeTranscriptSpeech. A host speech={{ value, onChange }} still overrides it (controlled mode). The picker lists the base widget's Web Speech catalogue (61 languages, with dialects, searchable), which covers all 17 bundled locales (zh is Mandarin, no is Bokmål); it has no free "other BCP-47" field. Touch devices hide the picker and the microphone (they have their own keyboard dictation). A host that shows no picker runs an older release: upgrade to 0.3.3 or later.
Message sounds (sent, answer starts, answer done, error) come from widget-chat and are on by default; messageSounds={false} silences them, messageSounds={{ volume, urls }} tunes them.
Dictation start/stop tones are bundled (Web Audio, no files). sounds={false} silences them; sounds={{ play(kind) }} replaces them.
Attachments
<AssistantChat attachments={{ upload, kinds, maxFileSize, maxFiles }} /> adds a paperclip, drop and image paste (defaults: image/audio/text/document, 10 MB, 5 files). The types are the chat's, re-exported from this package. upload is the host's; the default wire body gains attachments only when files were attached. Details: @djangocfg/widget-chat @docs/attachments.md.
Asking from outside, the dock and the locked room
askAssistant(text)puts a prompt typed elsewhere on the page (a hero search, a "discuss this" button) into the composer and sends it once the composer is mounted and sending is allowed. One slot: asking twice before the composer exists keeps the last.<AssistantHeaderActions><ChatMenu session={session} /></AssistantHeaderActions>, rendered besideAssistantPortal, publishes the header controls;AssistantFrameandChatDockshow them with no wiring between the files (an explicitactionsprop wins).AssistantFrameandChatDocktakeactions(header controls such as the "..." menu): they sit next to Close in the one panel header (ChatPanelHeaderfromwidget-chat, drawn identically by the wide column, the tablet side panel and the phone sheet), set apart by a thin divider; each button has a ui-core tooltip.ChatDockis a side panel on tablets, the base package'sChatSheeton phones. The keys list (AssistantHelp) is hidden on touch devices.- One placement rule, in the frame.
<AssistantFrame docked={open} />: from 1280 px the chat is a column beside the page; narrower, the frame itself opens it as a floating window bottom right (tablet; no backdrop, not modal) or a full-screen sheet (phone). A host renders noChatDock, no media query and no second surface. - A host with its own shell or transport (a call that polls its own lines):
AssistantFrameheading,help,tablet,windowBottom,stageMin,className,fill;AssistantChatstore,capabilities,renderers,footer,composer,readOnly; the entry@djangocfg/widget-assistant/shellleaves the SSE layer out of the graph. All optional; see@docs/interface.md. - One signed-out view, in the frame.
<AssistantFrame requireAuth lock={...} />(also onChatDock): a signed-in reader gets the chat, a reader the client is still identifying gets an empty body, anyone else gets a big icon, a reason and a Sign in button, centred in the full height of the column (no inert composer). Sign in saves the current page and goes to the django-cfg auth route (useAuthfrom@djangocfg/api/auth, a peer dependency);lock(AssistantLockOptions:title,body,signInLabel,icon,onSignIn,content) changes the words or replaces the block. Also exported:AssistantAuthGate,useAssistantAccess,useSignIn. A host writes no locked wrapper. AssistantLocked(the bare view; itscomposerLabelandonComposerClickare deprecated and ignored) is what the gate draws; use it directly only on a surface with no django-cfg auth.
Tests
pnpm --filter @djangocfg/widget-assistant check test: the wire, stream and boundary tests, and tsc --noEmit.
Migrating from 0.1 (a host that spoke the film frames)
0.2 breaks the wire. In the host's server: run carries thread_id and message_id (not run_id, user_message_id, assistant_message_id); status.phase is thinking|searching|answering (reading and choosing are gone); done carries no message id; a card payload is ui_payload {kind: 'film_cards', data} as before, but the package no longer validates it, so register it: dataRenderers={{ film_cards: … }} and payloadKinds={['film_cards']}. A stored message has parts: [{kind, data}] in place of widgets: [{kind, ids, caption}]. ChatContext is a plain record (the old title and browse kinds are the host's to define). createTransport still opens /apix/chat/v1/stream when no streamUrl is given; that default stays for anonymous hosts only; a signed-in host with sessions sets streamUrl. The dictionaries lose statusReading and statusChoosing and gain statusAnswering; the bundled lockedBody and hint no longer talk about films.
Migrating from 0.2 (one thread, clear())
0.3 replaces the single-thread ThreadApi { load, clear } with ThreadApi { list, create, load(id, signal) } (useSession takes an optional storage), removes Session.clear (use startNew), swaps the dictionary keys clear, clearConfirm, clearYes for newChat, sessions, sessionsEmpty, and adds ThreadNotFoundError, the not_found reject code, SendInput.threadId, AdapterDeps.threadFor / onActive, AssistantChat.draftRoom, SessionsMenu, useAssistantLocale and relativeTime. A host that kept a hand-written sessions bar can delete it.