@m6d/cortex-react 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @m6d/cortex-react
2
2
 
3
- React chat widget for cortex servers: streaming messages, tool call visualization, composer attachments, syntax highlighting, and i18n. Same features and pixel parity as `@m6d/cortex-angular`.
3
+ React chat widget for Cortex agents: streaming messages, tool call visualization, composer attachments, syntax highlighting, and i18n. `@m6d/cortex-angular` is the same widget for Angular.
4
4
 
5
5
  ## Usage
6
6
 
@@ -12,7 +12,7 @@ import '@m6d/cortex-react/theme.css';
12
12
  const config = {
13
13
  locale: 'en',
14
14
  transport: {
15
- baseUrl: '/api/agents/assistant',
15
+ baseUrl: 'https://engine.cortex.example.com/agents/support',
16
16
  getHeaders: () => ({ Authorization: `Bearer ${token}` }),
17
17
  },
18
18
  } satisfies CortexReactConfig;
@@ -22,16 +22,40 @@ export function Chat() {
22
22
  }
23
23
  ```
24
24
 
25
- `CortexReactConfig` is the shared `CortexConfig` plus a React-only `toolComponents` field (`Record<string, ComponentType<CortexToolComponentInputs>>`). Every other property, the client tools flow, and the media blocks are documented in [`packages/cortex-angular/README.md`](../cortex-angular/README.md); only the `toolComponents` and `injector` rows are Angular-specific.
25
+ `baseUrl` is the agent's URL: the engine's public origin (`ENGINE_PUBLIC_URL`) plus `/agents/<agent slug>`. Add the page's origin to `ENGINE_CORS_ORIGINS`, or proxy the engine under your own origin as the samples do. The WebSocket URL is `baseUrl` plus `/ws`; set `wsUrl` only to override it, and point it at that same agent-scoped path. The bearer is the end user's token, which Cortex verifies the way the agent's auth says: a JWKS, a shared secret, the agent's server, or nobody for an anonymous agent.
26
+
27
+ Each widget is self-contained, so one page can render several widgets with different configs. `className` sizes the widget's root element.
28
+
29
+ ## Configuration
30
+
31
+ `CortexReactConfig` is the shared `CortexConfig` plus one React-only field, `toolComponents`.
32
+
33
+ | Property | Type | Description |
34
+ | -------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------- |
35
+ | `locale` | `'en' \| 'ar'` | Active locale (default: `'en'`) |
36
+ | `transport.baseUrl` | `string \| () => string` | The agent's URL, `<engine origin>/agents/<slug>` |
37
+ | `transport.getHeaders` | `() => Headers \| Promise<Headers>` | Request headers provider; `Headers` is `Record<string, string>` |
38
+ | `wsUrl` | `string \| false` | WebSocket URL override; `false` disables the socket (optional) |
39
+ | `viewMode` | `'helper' \| 'full'` | Compact panel or full layout with a sidebar (default: `'helper'`) |
40
+ | `theme` | `'light' \| 'dark' \| 'system'` | Color scheme (default: `'light'`) |
41
+ | `agentName` | `string \| { en, ar }` | Header title while no thread is selected (optional) |
42
+ | `showDebugButton` | `boolean` | Show/hide the debug toggle button (default: `false`) |
43
+ | `mediaRenderers` | `Record<string, MediaRenderer>` | Replace or add the media fences the assistant can draw (optional) |
44
+ | `trustSameOriginEmbeds` | `boolean` | Allow embedded tool pages served from this page's own origin (default: `false`) |
45
+ | `hooks.onToolCall` | `(toolCall, context) => unknown` | Answers a client tool; return `undefined` for tools you do not own (optional) |
46
+ | `hooks.onThreadSelected` | `(thread) => void \| Promise<void>` | Runs when a thread opens (optional) |
47
+ | `hooks.onThreadDeselected` | `(thread) => void \| Promise<void>` | Runs when a thread closes (optional) |
48
+ | `toolComponents` | `Record<string, ComponentType<CortexToolComponentInputs>>` | Custom React components for tool rendering (optional) |
49
+
50
+ `transport.getHeaders` returns the headers every call carries, usually a bearer `Authorization`. Called straight from another origin, the engine accepts only `Authorization` and the headers the widget sets itself; route anything else through a same-origin proxy. The WebSocket takes its `token` query param from a bearer `Authorization` header when there is one.
26
51
 
27
52
  ## Custom parts
28
53
 
29
- With `slots` you draw the widget's parts yourself: `header`, `landing` (the
30
- blank chat) and `threadList` (the history's list). Each is a component that
31
- takes the context the default is drawn from as its props, the same one
32
- Angular's `cortexSlot` templates get (fields listed in the Angular README). The
33
- landing's `composer` is a React node to place. Define slot components outside
34
- render: a new component each render remounts the part.
54
+ Pass `slots` to draw one of the widget's parts yourself: `header`, `landing`
55
+ (the blank chat) or `threadList` (the history's list). Each is a component that
56
+ takes the context the default is drawn from as its props. Use only the slots
57
+ you need, and define them outside render: a new component each render remounts
58
+ the part.
35
59
 
36
60
  ```tsx
37
61
  import {
@@ -57,7 +81,12 @@ function Header(chat: CortexHeaderContext) {
57
81
  function Landing(landing: CortexLandingContext) {
58
82
  return (
59
83
  <div className="cortex-widget__landing">
60
- <h1>{landing.greeting}</h1>
84
+ {landing.greeting && <h1>{landing.greeting}</h1>}
85
+ {landing.suggestions.map((suggestion) => (
86
+ <button key={suggestion.prompt} onClick={() => landing.send(suggestion.prompt)}>
87
+ {suggestion.title}
88
+ </button>
89
+ ))}
61
90
  {landing.composer}
62
91
  </div>
63
92
  );
@@ -68,9 +97,145 @@ const slots = { header: Header, landing: Landing } satisfies CortexReactSlots;
68
97
  <CortexChatWidget config={config} slots={slots} />;
69
98
  ```
70
99
 
71
- The theming tokens and the table actions are the same as Angular's.
100
+ A slot replaces the whole part, so its root sits where the default's did: in
101
+ the chat or history screen's column, under the header. The defaults' classes
102
+ are there to reuse: `cortex-widget__chat-header`, `cortex-widget__chat-title`
103
+ and `cortex-widget__header-btn` for a header, `cortex-widget__landing` for a
104
+ centered landing, `cortex-widget__threads-list` for a scrolling list. Utility
105
+ classes such as Tailwind's apply inside the widget; its own reset sits in
106
+ `@layer base.cortex`, below them. `ThreadSummary` and `Suggestion` are exported
107
+ for typing your own rows and chips.
108
+
109
+ ### `header`
110
+
111
+ One header serves both screens; `screen` says which is showing.
112
+
113
+ | Field | Type | Description |
114
+ | -------------- | --------------------- | ------------------------------------------------------------------ |
115
+ | `screen` | `'chat' \| 'threads'` | Which screen the header is on |
116
+ | `viewMode` | `'helper' \| 'full'` | The configured view mode |
117
+ | `title` | `string \| null` | The open thread's title; `null` on a new chat |
118
+ | `agentName` | `string \| undefined` | `agentName` in the widget's locale |
119
+ | `threadCount` | `number \| undefined` | How many conversations the history lists; undefined while it loads |
120
+ | `isWorking` | `boolean` | The agent is answering |
121
+ | `debug` | `{ shown, on }` | Whether `showDebugButton` is set, and whether debug mode is on |
122
+ | `newChat` | `() => void` | Starts a new chat |
123
+ | `openHistory` | `() => void` | Shows the history: its screen, or the sidebar in full mode |
124
+ | `closeHistory` | `() => void` | Returns to the open chat |
125
+ | `toggleDebug` | `() => void` | Switches debug mode |
126
+
127
+ ### `landing`
128
+
129
+ Shown while no thread is open. The first send opens a thread and swaps the
130
+ landing for it.
131
+
132
+ | Field | Type | Description |
133
+ | ------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------- |
134
+ | `greeting` | `string` | The agent's greeting in the widget's locale, or the built-in one; empty until the handshake answers |
135
+ | `suggestions` | `{ title, prompt }[]` | Empty until the handshake answers, and for an agent that takes no messages |
136
+ | `threads` | `ThreadSummary[] \| undefined` | Newest first; `undefined` while the first load is out |
137
+ | `composer` | `ReactNode` | The widget's composer, or its "messages disabled" notice; place it in your markup |
138
+ | `send` | `(prompt: string) => void` | Sends a first message; does nothing until the handshake answers, or for an agent that takes no messages |
139
+ | `openThread` | `(thread: ThreadSummary) => void` | Opens a thread |
140
+ | `openHistory` | `() => void` | Shows the whole history, as the header's `openHistory` |
141
+
142
+ ### `threadList`
143
+
144
+ The history's list. Its header is the `header` slot.
145
+
146
+ | Field | Type | Description |
147
+ | ------------------ | --------------------------------- | ----------------------------------------------------- |
148
+ | `threads` | `ThreadSummary[] \| undefined` | Newest first; `undefined` while the first load is out |
149
+ | `selectedThreadId` | `string \| undefined` | The open thread's id; `undefined` on a new chat |
150
+ | `openThread` | `(thread: ThreadSummary) => void` | Opens a thread |
151
+ | `deleteThread` | `(threadId: string) => void` | Deletes a thread |
152
+ | `newChat` | `() => void` | Starts a new chat |
153
+
154
+ ## Theming
155
+
156
+ Every color is a `--cortex-color-*` token whose light and dark defaults ship
157
+ with the widget; `theme` picks the side. `@m6d/cortex-react/theme.css` lists every
158
+ token with its default, and any of them can be overridden on `:root` or on any
159
+ ancestor of the widget. Beside the colors, radius and `--cortex-font-size`,
160
+ four tokens ship unset and fall back when you leave them out:
161
+
162
+ | Token | Falls back to | Styles |
163
+ | -------------------------------- | --------------------------- | ------------------------------------ |
164
+ | `--cortex-font-size-message` | `--cortex-font-size` | Message text and the composer |
165
+ | `--cortex-font-size-header` | `--cortex-font-size` | The chat and history titles |
166
+ | `--cortex-color-send-empty` | `transparent` | The send button with nothing to send |
167
+ | `--cortex-color-send-empty-text` | `--cortex-color-text-muted` | Its icon |
168
+
169
+ The composer resets its own focus and disabled styles, so a page's global
170
+ `textarea:focus` ring or disabled fill does not reach it.
171
+
172
+ ## Tables
173
+
174
+ A markdown table in an answer scrolls sideways rather than squeezing its
175
+ columns. Once it has finished arriving, hovering it (or focusing into it)
176
+ shows two actions at its top end: expand, which opens it in a dialog with room
177
+ for every column, and export, which downloads it as an `.xlsx` file. Numbers
178
+ export as numbers; the header row is bold.
179
+
180
+ ## Client tools
181
+
182
+ Tools of type `embedded` render inside the chat as an embedded flow (payment,
183
+ signature, ...): the widget asks Cortex to initiate, shows the returned page in
184
+ a sandboxed iframe (inline card or modal, per tool config), and submits the
185
+ page's `cortex:client-tool` postMessage outcome back to the run as-is.
186
+ Anything that must actually be true lives in your backend's own records. No
187
+ widget configuration is needed. One caveat: an embed served from the page's
188
+ **own origin** is refused by default, because a same-origin iframe cannot be
189
+ sandboxed: it could reach into the page, and embed URLs come from the tool's
190
+ definition in Cortex, whose administrator is not necessarily the site owner.
191
+ When both are the same party and the embeds are the site's own pages, set
192
+ `trustSameOriginEmbeds: true` to render them anyway (with full access, as any
193
+ of the site's own pages has).
194
+
195
+ Other pending tool calls go to `toolComponents` or `hooks.onToolCall`.
196
+ That is how a tool of type `client` ("Answered by your app" in the console)
197
+ works: return its output from `hooks.onToolCall` for that tool name, or draw it
198
+ with a tool component. A `hooks.onToolCall` must return `undefined` for tool
199
+ names it does not own, since answering an unknown pending call would settle an
200
+ embedded flow before the user completes it.
201
+
202
+ ## Media blocks
203
+
204
+ The assistant can answer with more than text. A fenced block tagged `mermaid`,
205
+ `vega-lite`, `map` or `report` renders as a diagram, chart, map or report
206
+ inside the message, with a placeholder while the block is streaming and an
207
+ error card (with the source) when it cannot be drawn. An image attachment the
208
+ model references as `![alt](attachment#<id>)` on its own line renders inline.
209
+
210
+ The renderers are peer dependencies your bundler must be able to resolve, but
211
+ they load only when a block of their kind first appears: `mermaid` for
212
+ diagrams, `vega`, `vega-lite` and `vega-embed` for charts, and `leaflet` for
213
+ maps (plus its stylesheet, `leaflet/dist/leaflet.css`, in your page). Maps
214
+ draw OpenStreetMap tiles and need no key. A block whose library fails to load
215
+ shows the error card.
216
+
217
+ A report is an HTML fragment the agent writes. The widget removes scripts,
218
+ images, links and anything that would load a URL, draws the rest in a shadow
219
+ root that takes its colors from your `--cortex-color-*` theme, and draws each
220
+ `<figure data-chart>` inside it with the chart renderer. Which forms an agent
221
+ writes is its `media` setting in Cortex; reports are off by default.
222
+
223
+ `mediaRenderers` swaps a renderer or adds a fence tag of your own. A renderer
224
+ draws a closed fence's source into the host element and returns its teardown;
225
+ its context carries the widget's color `scheme` and `locale`:
226
+
227
+ ```ts
228
+ mediaRenderers: {
229
+ map: async (host, source, { scheme }) => {
230
+ // draw with Google Maps, Mapbox, MapLibre, ...
231
+ return () => {
232
+ /* dispose */
233
+ };
234
+ },
235
+ }
236
+ ```
72
237
 
73
238
  ## Requirements
74
239
 
75
240
  - React 18 or later
76
- - Peer dependencies: `@tanstack/ai`, `@tanstack/ai-client`, `@tanstack/ai-react`, `marked`, `dompurify`, `highlight.js`, plus the lazily loaded `mermaid`, `vega`, `vega-lite`, `vega-embed`, `leaflet` (and `leaflet/dist/leaflet.css` in your page for maps)
241
+ - Peer dependencies: `@tanstack/ai`, `@tanstack/ai-client`, `@tanstack/ai-react`, `marked`, `dompurify`, `highlight.js`, plus the lazily loaded `mermaid`, `vega`, `vega-lite`, `vega-embed`, `leaflet`
package/dist/index.d.ts CHANGED
@@ -19,6 +19,14 @@ export type ThreadSummary = {
19
19
  name: string;
20
20
  };
21
21
  };
22
+ /**
23
+ * Body of `POST /threads`. Only a console operator's request is read: its
24
+ * test thread keeps `variables` as the session the agent's server would
25
+ * otherwise fill per end user.
26
+ */
27
+ export type CreateThreadRequest = {
28
+ variables?: Record<string, string | number | boolean>;
29
+ };
22
30
  /**
23
31
  * One landing-page chip: the widget shows `title` and sends `prompt` as the
24
32
  * first message when clicked. The two may be the same text.
@@ -29,13 +37,19 @@ export type Suggestion = {
29
37
  };
30
38
  export type CortexHeaders = Record<string, string>;
31
39
  /**
32
- * How the widget reaches its cortex server. `baseUrl` may be a thunk so a host
33
- * with a reactive origin (an Angular Signal is itself a `() => string`) is read
40
+ * How the widget reaches Cortex. `baseUrl` may be a thunk so a consumer with
41
+ * a reactive origin (an Angular Signal is itself a `() => string`) is read
34
42
  * at the moment of each request rather than captured once.
35
43
  */
36
44
  export type CortexTransport = {
37
45
  baseUrl: string | (() => string);
38
46
  getHeaders: () => CortexHeaders | Promise<CortexHeaders>;
47
+ /**
48
+ * Sent with every thread the widget creates. The console's playground puts
49
+ * a test thread's variables here; Cortex honours it only from a verified
50
+ * console operator.
51
+ */
52
+ threadBody?: CreateThreadRequest;
39
53
  };
40
54
  export type ColorScheme = "light" | "dark";
41
55
  export type MediaRenderContext = {
@@ -59,7 +73,7 @@ export type CortexTheme = "light" | "dark" | "system";
59
73
  export type CortexToolCallContext = {
60
74
  threadId: string;
61
75
  };
62
- /** A tool the server left for the client to answer. */
76
+ /** A tool call Cortex left for the client to answer. */
63
77
  export type CortexToolCall = {
64
78
  toolCallId: string;
65
79
  toolName: string;
@@ -112,16 +126,16 @@ export type CortexConfig = {
112
126
  * Render embedded tool pages served from this page's own origin with their
113
127
  * full identity — which, same-origin, means no effective sandbox at all: such
114
128
  * an embed can reach into the host page. By default they are refused
115
- * (rendered inert), because embed URLs come from Control Center tool config,
116
- * and whoever administers that is not necessarily whoever owns this site.
117
- * Enable only when both are the same party and the embeds are this site's
118
- * own pages — then a second origin just for embeds is no longer needed.
129
+ * (rendered inert), because embed URLs come from the tool's definition in
130
+ * Cortex, and whoever administers that is not necessarily whoever owns this
131
+ * site. Enable only when both are the same party and the embeds are this
132
+ * site's own pages, so the embeds need no origin of their own.
119
133
  */
120
134
  trustSameOriginEmbeds?: boolean;
121
135
  hooks?: CortexHooks;
122
136
  };
123
137
  /**
124
- * What the header receives, the host's own or the widget's: where the widget
138
+ * What the header receives, the consumer's own or the widget's: where the widget
125
139
  * is, what to show, and the actions the default header offers. One header
126
140
  * serves both screens; `screen` says which is showing.
127
141
  */
@@ -132,10 +146,10 @@ export type CortexHeaderContext = {
132
146
  title: string | null;
133
147
  /** `agentName` in the widget's locale. */
134
148
  agentName: string | undefined;
135
- /** How many conversations the history lists; 0 while it loads. */
136
- threadCount: number;
149
+ /** How many conversations the history lists; undefined while the first load is out. */
150
+ threadCount: number | undefined;
137
151
  isWorking: boolean;
138
- /** Whether the host enabled the debug switch, and whether it is on. */
152
+ /** Whether the consumer enabled the debug switch, and whether it is on. */
139
153
  debug: {
140
154
  shown: boolean;
141
155
  on: boolean;
@@ -148,13 +162,13 @@ export type CortexHeaderContext = {
148
162
  toggleDebug: () => void;
149
163
  };
150
164
  /**
151
- * What the blank-chat landing receives, the host's own or the widget's. The
165
+ * What the blank-chat landing receives, the consumer's own or the widget's. The
152
166
  * composer arrives ready to place as the SDK's own view type (`View`), so a
153
167
  * custom landing keeps the widget's attachments and send rules. Each SDK
154
168
  * exports it as `CortexLandingContext` with its view type filled in.
155
169
  */
156
170
  export type LandingContext<View> = {
157
- /** The agent's greeting in the widget's locale, or the built-in one. */
171
+ /** The agent's greeting in the widget's locale, or the built-in one; empty until the handshake answers. */
158
172
  greeting: string;
159
173
  /** Empty until the handshake answers, and for an agent that takes no messages. */
160
174
  suggestions: Suggestion[];
@@ -171,7 +185,7 @@ export type LandingContext<View> = {
171
185
  /** Shows the whole history: its screen, or the sidebar in full mode, as the header's `openHistory`. */
172
186
  openHistory: () => void;
173
187
  };
174
- /** What the history's list receives, the host's own or the widget's. Its header is the header slot. */
188
+ /** What the history's list receives, the consumer's own or the widget's. Its header is the header slot. */
175
189
  export type CortexThreadListContext = {
176
190
  /** Newest first; undefined while the first load is out. */
177
191
  threads: ThreadSummary[] | undefined;
@@ -181,7 +195,7 @@ export type CortexThreadListContext = {
181
195
  deleteThread: (threadId: string) => void;
182
196
  newChat: () => void;
183
197
  };
184
- /** Each part of the widget a host can draw itself, with the context it receives. */
198
+ /** Each part of the widget a consumer can draw itself, with the context it receives. */
185
199
  export type CortexSlotContexts<View> = {
186
200
  header: CortexHeaderContext;
187
201
  landing: LandingContext<View>;
@@ -193,7 +207,7 @@ export type CortexReactConfig = CortexConfig & {
193
207
  /** The landing's context, its composer a React node to place. */
194
208
  export type CortexLandingContext = LandingContext<ReactNode>;
195
209
  /**
196
- * The parts of the widget a host draws itself: each a component taking the
210
+ * The parts of the widget a consumer draws itself: each a component taking the
197
211
  * part's context as its props, the same type as the widget's default.
198
212
  */
199
213
  export type CortexReactSlots = {
@@ -202,10 +216,10 @@ export type CortexReactSlots = {
202
216
  /** The chat widget: the thread list, the open conversation and its composer, for the agent `config` points at. */
203
217
  export declare function CortexChatWidget({ config, className, slots, }: {
204
218
  config: CortexReactConfig;
205
- /** Sizing classes for the host. */
219
+ /** Sizing classes for the widget's root element. */
206
220
  className?: string;
207
221
  /**
208
- * The host's own parts. Each given one replaces the widget's default and
222
+ * The consumer's own parts. Each given one replaces the widget's default and
209
223
  * receives the context the default is drawn from as its props. `header`
210
224
  * serves both the chat and the history screen. Pass components defined
211
225
  * outside render: a new one each render remounts the part.