teko-assistant-ui
v1.1.1
Published
teko-assistant-ui - AI assistant UI SDK for Teko apps
Readme
teko-assistant-ui
A React chat-panel SDK for embedding an AI assistant into apps. The SDK is a pure chat panel — the host app owns the trigger, container, position, and visibility; the panel fills 100% of its parent. It talks to your BFF over Server-Sent Events using the OpenAI Chat Completions streaming format.
Contents: Install · Quick start · Core concepts · Reacting to tool calls · Suggestions · API reference · BFF protocol
How it works
Host App
└── <AssistantPanel chatBffUrl="..." />
│ Server-Sent Events (OpenAI-compatible streaming)
▼
BFF → LLM → Business ServicesInstall
yarn add teko-assistant-ui
# peer deps
yarn add react react-domQuick start
You provide the trigger button and the container; the panel fills its parent. The minimum is appId + chatBffUrl:
import { AssistantPanel } from 'teko-assistant-ui';
// Place the panel in any container you like (sidebar, drawer, modal…)
<div style={{ position: 'fixed', right: 0, top: 0, width: 380, height: '100vh' }}>
<AssistantPanel
appId="your-app-id"
chatBffUrl="https://your-bff.example.com/chat"
// Auth/context go through HTTP headers; the SDK also adds x-session-id + x-user-id
getRequestHeaders={async () => ({
'x-authorization': await getAccessToken(),
})}
/>
</div>;That gives you a working streaming chat. Everything below is optional — add it as you need it.
Core concepts
The SDK streams assistant replies and renders them. Beyond plain text, the LLM (via your BFF) can emit tool calls — a request for the app to act, shaped as { name, arguments }. The SDK surfaces each tool call to the host through three primitives:
| Primitive | When it fires | What the host does |
| ----------------------- | ---------------------------- | ------------------------------------------------------------------------ |
| onToolCall(name, args) | every tool call | Primary, generic — react however you want (open a drawer, navigate, apply a change…) |
| tools[name] | a message has tool call name | Render a React component inline in the chat (e.g. a product card) |
| sendContext(data) | after a user action | Push context back to the AI (bidirectional) — no need to retype |
Tool calls are dispatched eagerly — each fires as soon as it completes mid-stream, not at the end — and multiple tool calls per turn are supported (accumulated by OpenAI index).
To render clickable suggestion chips, add
suggestions[name] = { map, behavior }(map tool args → chips;behavior='send'|'prefill'| custom). Optional — see Suggestions.
Reacting to tool calls
onToolCall is the catch-all for everything the LLM asks the app to do. tools renders an inline component for a specific tool call.
<AssistantPanel
appId="your-app-id"
chatBffUrl="https://your-bff.example.com/chat"
getRequestHeaders={async () => ({ 'x-authorization': await getAccessToken() })}
// Catch-all: react to any tool call from the LLM
onToolCall={(name, args) => {
if (name === 'VIEW_CART') openCartDrawer(args.cart_token);
if (name === 'APPLY_PATCH') applyPatch(args);
}}
// Render an inline component for a specific tool call
tools={{ PRODUCT_DETAIL: ProductDetailCard }}
/>;An inline tool component can push context back to the AI after the user acts (bidirectional):
import { useAssistantContext } from 'teko-assistant-ui';
function ProductDetailCard({ args }: { args: Record<string, unknown> }) {
const { sendContext } = useAssistantContext();
return (
<button onClick={() => sendContext({ type: 'added_to_cart', sku: args.sku })}>
Add to cart
</button>
);
}useAssistantContext() returns { sendContext, sendMessage }. The host can also call these via the panel ref — see AssistantPanelRef.
Suggestions
Render clickable suggestion chips under a message. Each suggestions[name] is a { map, behavior } config — map turns a tool call's args into chips; behavior decides what a click does. Render and click logic live together per tool name.
behavior (default 'send') has 3 modes:
'send'— sendoption.labelas a message immediately.'prefill'— putoption.labelinto the input for the user to edit/send.- function — custom:
(option, { sendContext, sendMessage, fillInput }) => void.
type Product = { sku: string; name: string; canonical?: string };
<AssistantPanel
/* …appId, chatBffUrl, getRequestHeaders… */
suggestions={{
INTENT_PRODUCT_SEARCH: {
// args → chips (return void for none); may be async
map: (args) => {
const products = args.products as Product[] | undefined;
return products?.slice(0, 8).map((p) => ({
key: p.sku,
label: p.name,
payload: { sku: p.sku, canonical: p.canonical },
}));
},
// custom click handler
behavior: (option, { sendContext, sendMessage }) => {
sendContext(option.payload ?? {});
sendMessage(option.label);
},
},
QUICK_REPLIES: {
map: (args) => (args.replies as string[]).map((r) => ({ key: r, label: r })),
behavior: 'prefill', // drop the text into the input instead of sending
},
}}
/>;SuggestOption is { key: string; label: string; payload?: Record<string, unknown> }.
API reference
<AssistantPanel> props
Required
| Prop | Type | Description |
| ------------ | -------- | ---------------- |
| appId | string | App identifier |
| chatBffUrl | string | BFF endpoint URL |
Tool handling
| Prop | Type | Description |
| ------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| onToolCall | (name: string, args: Record<string, unknown>) => void | Catch-all for every tool call (open a drawer, navigate, apply a change…). |
| tools | Record<string, ToolUIComponent> | Render a React component inline when a message has a tool call of that name. |
| suggestions | Record<string, SuggestionConfig> | Per-tool-name { map, behavior } → suggestion chips. See Suggestions. |
| getRequestHeaders | () => Record<string, string> \| Promise<...> | Headers attached to every request. The SDK adds x-session-id + x-user-id; the host supplies auth. |
Request & state
| Prop | Type | Description |
| ----------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| extraBody | Record<string, unknown> | Extra fields merged into the request body next to messages (e.g. { stream: true }, { model: '…' }). Memoize if passed inline. |
| conversationId | string | Controlled conversation identity. Pass a stable id per scope (e.g. page-${id}) for per-page sessions. Changing it resets messages; history is restored per id only when persistHistory is on. |
| onLoadingChange | (isStreaming: boolean) => void | Fires true when a turn starts streaming, false when it ends — use it to lock/unlock UI (e.g. disable an editor while generating). |
| mapError | (error: StreamError) => string \| undefined | Map a structured BFF stream error to display text. Return undefined → falls back to labels.connectionError. Without it, raw BFF error text is not shown (safe default). |
History
| Prop | Type | Default | Description |
| ----------------- | --------- | ------- | ------------------------------------------------------------------------------------------------- |
| persistHistory | boolean | false | Persist chat history to localStorage. Survives reload; scroll up to load older messages. |
| historyPageSize | number | 10 | Messages loaded per page (on mount and when scrolling up). |
UI & lifecycle
| Prop | Type | Default | Description |
| --------------------- | -------------------------- | ----------- | --------------------------------------------------------------------------- |
| primaryColor | string | '#1a73e8' | Primary color (hex). |
| branding | BrandingConfig | — | Logo, background, status-dot color, sender-name toggle. |
| locale | 'vi' \| 'en' | 'vi' | UI language. |
| labels | Partial<ChatLabels> | — | Override any user-facing string. |
| quickActions | QuickAction[] | — | Fixed action buttons above the input. Omit → nothing renders. |
| isOpen | boolean | — | Whether the panel is visible — drives unread counting (see below). |
| onUnreadCountChange | (count: number) => void | — | Fires when the unread count changes (for a trigger-button badge). |
| onClose | () => void | — | Fires when the user clicks the header close (X). Host unmounts/hides. |
| onDebugEvent | (e: DebugEvent) => void | — | Dev only — receive request/response/context debug events. |
loadingPhrasesis deprecated — the "typing" state now useslabels.typingText.
BrandingConfig
| Field | Type | Default | Description |
| ------------------- | --------- | ------- | ---------------------------------------------------------------------------- |
| logoUrl | string | — | Logo in the header + typing indicator. Omit → default avatar. |
| backgroundColor | string | white | Panel background (message area + input). |
| statusColor | string | — | "Online" status-dot color in the header. |
| showMessageSender | boolean | true | Show the sender name next to the timestamp under each message. |
When you set
branding.statusColor, overrideagentStatusto text without the●(the SDK renders its own colored dot), e.g.'Online'.
ChatLabels
Every user-facing string is overridable via labels (merged with the built-in vi/en set). Defaults below are the vi locale.
| Field | vi default | Description |
| ---------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------ |
| agentName | Tư vấn viên AI | Brand name in the header and under AI messages. |
| agentStatus | ● Trực tuyến | Status text under the brand name. |
| userName | Bạn | Name shown under the user's messages. |
| typingText | Đang soạn tin nhắn | Text next to the logo while the AI is typing (SDK appends an animated … — don't add ...). |
| inputPlaceholder | Nhập tin nhắn... | Input placeholder. |
| connectionError | Đã xảy ra lỗi khi kết nối. Vui lòng thử lại. | Shown on a transport/HTTP error (and stream errors with no mapError). |
| reasoningRunning | 🤔 Đang suy nghĩ... | Reasoning toggle — running. |
| reasoningDone | 💭 Đã suy nghĩ | Reasoning toggle — done. |
| reasoningPlaceholder | Đang xử lý... | Reasoning placeholder content. |
| close | Đóng | aria-label of the close button. |
| emptyState | Xin chào! Tôi có thể giúp bạn. | Shown when there are no messages. |
| loadMoreHint | Cuộn lên để xem thêm | Hint when older history exists (persistHistory). |
| loadMoreLoading | Đang tải thêm tin nhắn... | Shown while loading more. |
QuickAction
Fixed buttons above the input; each handles its own behavior via onClick (navigate, call chatRef.sendMessage(...), …).
interface QuickAction {
key: string;
label: string;
onClick: () => void;
}
<AssistantPanel
quickActions={[
{ key: 'category', label: 'Browse', onClick: () => router.push('/c') },
{ key: 'combo', label: 'Combos', onClick: () => chatRef.current?.sendMessage('Suggest a combo') },
]}
/>;Unread badge
isOpen + onUnreadCountChange let the host show a badge on its trigger button. The count resets to 0 when isOpen is true and the tab is visible, or when markAllRead() is called.
const [chatOpen, setChatOpen] = useState(false);
const [unread, setUnread] = useState(0);
<AssistantPanel isOpen={chatOpen} onUnreadCountChange={setUnread} /* … */ />;
{!chatOpen && (
<button onClick={() => setChatOpen(true)}>
Chat{unread > 0 && <span>{unread > 9 ? '9+' : unread}</span>}
</button>
)}AssistantPanelRef methods
| Method | Signature | Description |
| ------------- | ----------------------------------------- | ----------------------------------------------------------------- |
| sendMessage | (text: string) => void | Send a message programmatically. |
| sendContext | (data: Record<string, unknown>) => void | Queue context — merged into the next request's system message. |
| markAllRead | () => void | Mark all messages read — call when the host opens the panel. |
BFF protocol
The SDK speaks Server-Sent Events in the OpenAI Chat Completions streaming format.
- Request:
POST {chatBffUrl}withAccept: text/event-stream. Body is{ messages }(OpenAI format); the host'ssendContextdata is serialized into asystemmessage. Extra body fields come fromextraBody. - Response: SSE framing
data: {...}\n\n, terminated bydata: [DONE]\n\n. Each chunk is an OpenAI-compatible delta:choices[].delta.content→ text (rendered in real time).choices[].delta.tool_calls[]→ tool calls (accumulated byindex; multiple supported; each emitted as soon as it completes).choices[].finish_reasonor[DONE]→ end of turn.
- Mid-stream error: a chunk shaped
{ "error": { "code": 429, "message": "…" } }is passed tomapError(the host maps it to text); withoutmapError,labels.connectionErroris shown. HTTP/parse errors also useconnectionError.
Contributing
See DEVELOPMENT.md for dev setup, the Playground, project structure, and the release process.
