@filigran/chatbot 3.14.0 → 3.15.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/CHANGELOG.md +277 -0
- package/README.md +542 -290
- package/dist/index.d.ts +468 -12
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/markdown.d.ts +58 -1
- package/dist/markdown.js +1 -1
- package/dist/markdown.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/workspaces.d.ts +99 -0
- package/dist/workspaces.js +2 -0
- package/dist/workspaces.js.map +1 -0
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ Filigran chat panel — a standalone React + Tailwind chatbot component with SSE
|
|
|
20
20
|
- 🎙️ **Dictation** — Speech-to-text via the browser's own Web Speech API; no endpoint, no key, hidden where unsupported
|
|
21
21
|
- ✍️ **Draft Recovery** — Unsent composer text is kept per conversation and restored when the panel reopens
|
|
22
22
|
- 🎨 **Customizable Theme** — Accent color and logo customization
|
|
23
|
-
- 📱 **
|
|
23
|
+
- 📱 **4 Display Modes** — Floating, sidebar (resizable), fullscreen and inline
|
|
24
24
|
- 💾 **Persistence** — Conversation and sidebar width saved to localStorage
|
|
25
25
|
- 🛠️ **Tool Tracking** — Visual indicators for AI tool usage
|
|
26
26
|
|
|
@@ -66,34 +66,317 @@ import { ChatPanel } from '@filigran/chatbot';
|
|
|
66
66
|
|
|
67
67
|
| Prop | Type | Default | Description |
|
|
68
68
|
| ------------------- | ----------------------------------------- | ------------ | ---------------------------------------------------------------- |
|
|
69
|
-
| `mode` | `'floating' \| 'sidebar' \| 'fullscreen'` | **required** | Display mode
|
|
70
|
-
| `onClose` | `() => void` |
|
|
71
|
-
| `onModeChange` | `(mode: ChatMode) => void` |
|
|
69
|
+
| `mode` | `'floating' \| 'sidebar' \| 'fullscreen' \| 'inline'` | **required** | Display mode; `inline` fills the host's container — see [Inline mode and the handle](#inline-mode-and-the-handle) |
|
|
70
|
+
| `onClose` | `() => void` | — | Called when the close button is clicked; the button shows only with it (never in inline mode) |
|
|
71
|
+
| `onModeChange` | `(mode: ChatMode) => void` | — | Called when the user switches display mode; the view switcher shows only with it (never in inline mode) |
|
|
72
72
|
| `apiBaseUrl` | `string` | **required** | Base URL for chat API endpoints |
|
|
73
|
+
| `backendType` | `'rest' \| 'legacy' \| 'ag-ui'` | `'rest'` | The backend's protocol: XTM One's Chat API, a single POST endpoint with Flowise-style SSE events, or [AG-UI](https://github.com/ag-ui-protocol/ag-ui) |
|
|
73
74
|
| `user` | `{ firstName: string }` | **required** | Current user info |
|
|
74
75
|
| `topOffset` | `number` | `0` | Top offset in pixels (for sidebar/fullscreen with fixed headers) |
|
|
75
|
-
| `agentDashboardUrl` | `string` | — | XTM One URL for the "Browse agents" / "Create agent" links and the waiting panel's "Play Space Invaders in XTM One" link (`{url}/chat?arcade=play`, http(s) only) |
|
|
76
|
+
| `agentDashboardUrl` | `string` | — | XTM One URL for the "Browse agents" / "Create agent" links and the waiting panel's "Play Space Invaders in XTM One" link (`{url}/chat?arcade=play`, http(s) only, unless `waitingGameUrl` says where) |
|
|
77
|
+
| `waitingGameUrl` | `string` | — | Where the waiting panel's invitation to play leads, over `{agentDashboardUrl}/chat?arcade=play`: an http(s) URL or a path of the host's origin (`/…`); anything else links nothing |
|
|
76
78
|
| `miniGameEnabled` | `boolean` | `true` | Show the waiting panel during longer waits: rotating messages and the invitation to play XTM One's arcade (the package ships no game of its own) |
|
|
77
79
|
| `onPlayWaitingGame` | `() => void` | — | Open the arcade in place instead of linking to XTM One, for a host that runs it itself (the XTM One floating assistant) |
|
|
78
|
-
| `t` | `(key: string) => string` |
|
|
80
|
+
| `t` | `(key: string) => string` | — | The host's translations; a key it returns unchanged falls back to the panel's catalog in `locale` (none without `locale`) — see [Internationalization](#internationalization) |
|
|
79
81
|
| `accentColor` | `string` | `'#7b5cff'` | Primary accent color (hex) |
|
|
80
82
|
| `logoIcon` | `React.ReactNode` | default icon | Custom logo/icon for the assistant |
|
|
81
83
|
| `promptSuggestions` | `string[]` | default list | Prompt suggestions shown on welcome screen |
|
|
84
|
+
| `draftBorderColor` | `string` | — | Color of the line above the composer (e.g. a draft context) |
|
|
85
|
+
| `draftLabel` | `string` | — | Accepted, and not shown |
|
|
82
86
|
| `pageContext` | `Record<string, unknown>` | — | Arbitrary host page context (e.g. `{ url: '/dashboard/...' }`) sent as `context` on each `rest` message so the agent knows where the user is. Must be JSON-serializable (skipped if not). Read fresh at send time; omitted when empty. |
|
|
83
87
|
| `resizable` | `boolean` | `false` | Enable drag-to-resize for sidebar mode |
|
|
84
88
|
| `onWidthChange` | `(width: number) => void` | — | Called when sidebar width changes during resize |
|
|
85
89
|
| `onResizeStart` | `() => void` | — | Called when resize drag starts |
|
|
86
90
|
| `onResizeEnd` | `() => void` | — | Called when resize drag ends |
|
|
91
|
+
| `pushContentSelector` | `string` | — | CSS selector of the host's content, given a right padding of the sidebar's width while the panel is in sidebar mode (`--chatbot-sidebar-width` on `:root` holds it either way) |
|
|
92
|
+
| `disableFileManagement` | `boolean` | `false` | No attaching or pasting files in the composer |
|
|
93
|
+
| `maxFileCount` | `number` | `10` | Most files attached to one message |
|
|
94
|
+
| `maxTotalSize` | `number` | 50 MB | Most bytes the files attached to one message add up to |
|
|
87
95
|
| `onMessageFeedback` | `(id, feedback, message) => void` | — | Receives each rating of an answer (`null` clears it); `id` is the message's persisted id when the backend reported one (`done.message_id`, a restored entry's `id`), else the panel's own. On its own it enables 👍/👎 and the panel stores nothing; with `apiEndpoints.feedback` set it is called once the backend has stored the rating. With neither, no thumbs. |
|
|
88
|
-
| `locale` | `string` | browser's | BCP 47 language tag the
|
|
96
|
+
| `locale` | `string` | browser's | BCP 47 language tag of the platform's language (e.g. `'fr-FR'`): the language of the panel's catalog for what `t` does not translate, of its message times and of read-aloud. Unset, the browser's — except that a host passing `t` gets no catalog then |
|
|
89
97
|
| `disableImagePreviews` | `boolean` | `false` | Render image attachments as download cards instead of inline previews |
|
|
90
98
|
| `contextUsageEnabled` | `boolean` | `true` | Show how full the model's context window is for the current conversation (ring + percentage in the composer toolbar). Data-driven, so it stays absent until the backend reports occupancy — see [Context usage](#context-usage). |
|
|
99
|
+
| `notifyOnComplete` | `boolean` | `true` | Tell the user a long turn ended while they were not watching the chat: away (the tab hidden, another window), the page title flashes and a browser notification shows (when already granted); away or with the panel closed or hidden, `onTaskComplete` is called |
|
|
100
|
+
| `onTaskComplete` | `(title: string, body: string) => void` | — | The host's own toast for a long turn that ended while the user was not watching the chat, with the translated `title` and `body` |
|
|
91
101
|
| `composerToolbar` | `React.ReactNode` | — | Extra controls appended to the composer toolbar. The escape hatch for host-specific affordances (XTM One's session-tool picker) — the package never learns what they are. Pass nothing and the toolbar simply has none. |
|
|
102
|
+
| `storageKeyPrefix` | `string` | — | Namespaces every key the panel keeps in browser storage (`<prefix>:filigranChatConversationId`, …), for a host mounting more than one panel |
|
|
103
|
+
| `conversationId` | `string \| null` | — | The conversation on screen when the host owns it (a `?c=` URL); `null` is a new chat. Left `undefined`, the panel remembers its own, and a host-owned one is never written to storage |
|
|
104
|
+
| `onConversationChange` | `(id: string \| null) => void` | — | Called whenever the conversation on screen changes, whoever changed it (a first answer, New chat, the history) |
|
|
105
|
+
| `initialAgent` | `string` | — | Agent selected once the list loads, by id or slug, over the remembered one |
|
|
106
|
+
| `onAgentChange` | `(agent: XtmAgent) => void` | — | Called when another agent is picked |
|
|
107
|
+
| `readOnly` | `boolean` | `false` | Hides the composer: a conversation the user may read but not write to |
|
|
108
|
+
| `readOnlyNotice` | `React.ReactNode` | — | Shown in place of the composer while `readOnly` |
|
|
109
|
+
| `getRequestExtras` | `() => Record<string, unknown>` | — | Fields merged into every message body, read at send time (the host's own per-message options). They never override the panel's fields |
|
|
110
|
+
| `onStreamEvent` | `(event, { relayed, end?, replayed? }) => void` | — | Every frame received, on the message stream or the [event stream](#turns-that-outlive-their-stream) (`relayed: true`), including those the panel does not render, with what the panel read in it (`end`: `'provisional'` or `'stored'`; `replayed`) — see [Host extensions](#host-extensions) |
|
|
111
|
+
| `onSessionEnded` | `(reason: string \| undefined) => void` | — | The user's session ended: a `session_expired` frame on either stream, or a `401` whose `detail.code` is `session_expired` on any request the panel makes; *reason* is the frame's or the `detail`'s `reason`. Called once per set of `requestHeaders` |
|
|
112
|
+
| `markerRenderers` | `Record<string, ChatMarkerRenderer>` | — | The host's cards for answer markers, by name — see [Host extensions](#host-extensions) |
|
|
113
|
+
| `codeBlockRenderers` | `Record<string, ChatCodeBlockRenderer>` | — | The host's rendering of fenced code blocks, by language: `(code, { streaming }) => React.ReactNode` — see [Host extensions](#host-extensions) |
|
|
114
|
+
| `transformMessageText` | `(text, message) => string` | — | Rewrites a message's text before it is shown, copied or read aloud; one that throws leaves the text as it came |
|
|
115
|
+
| `onArtifactOpen` | `(artifact: ChatArtifact) => void` | — | Opens a deck, report or video in the host, in place of the `Open in XTM One` link |
|
|
116
|
+
| `headerActions` / `banner` / `composerHeader` / `transcriptFooter` / `welcome` | `React.ReactNode` | — | The host's content in the header, above the thread, above the composer, after the last message, or in place of the welcome screen |
|
|
117
|
+
| `renderMessageExtras` / `messageFooterActions` | `(message, { streaming }) => React.ReactNode` | — | The host's content under a message, and controls in a finished answer's footer; `streaming` while the message is the answer still being written. `message.raw` holds the backend's own fields — see [Host extensions](#host-extensions) |
|
|
118
|
+
| `sidebarRowActions` | `(conversation) => React.ReactNode` | — | The host's controls on a row of the conversation list |
|
|
119
|
+
| `sidebarHeaderActions` / `sidebarSections` | `React.ReactNode` | — | The host's controls beside New conversation, and sections under the list |
|
|
120
|
+
| `statusLabel` | `(status: AgentStatusState) => string \| undefined` | — | The status line's text for a status, over the built-in one |
|
|
121
|
+
| `waitingMessages` | `string[]` | built-in | The waiting panel's rotating messages, already translated |
|
|
122
|
+
| `renderWaitingPanel` | `() => React.ReactNode` | — | Replaces the waiting panel's content (its messages and the invitation to play) |
|
|
123
|
+
| `fontFamily` | `string` | — | Font of the panel's text |
|
|
124
|
+
| `agentMentions` | `boolean` | `false` | `@` also mentions agents of the agent list; the message carries their ids as `mentioned_agent_ids` |
|
|
125
|
+
| `steerAttachments` | `boolean` | `false` | Uploaded files may steer the running turn (the steer route takes `file_ids`) |
|
|
126
|
+
| `onReady` | `() => void` | — | Called once, when the panel has chosen its agent and read back the conversation on screen; handle calls made earlier wait for it and then run in order |
|
|
127
|
+
| `onHeldSendSettled` | `(text, outcome) => void` | — | What became of a message held until the turn ended (`send` resolved `'held'`): `'sent'`, `'failed'`, `'dropped'`, `'returned'` (given back to the composer: held when Stop ended its turn, or taken over by `onBeforeSend` as it leaves; told once the composer shows it, so `getDraft()` reads it) or `'cancelled'`. A message sent after a stopped turn ended goes out once that turn winds down |
|
|
128
|
+
| `onTurnRunningChange` | `(running: boolean, info?: ChatTurnEnd) => void` | — | A turn started or stopped running on screen, own or relayed: the state of the Stop button and of `draft.turnRunning`; with `false` comes how it ended, `info.ended`: `'answered'`, `'stopped'` or `'failed'` — see [Host extensions](#host-extensions). After a provisional end (see [Turns that outlive their stream](#turns-that-outlive-their-stream)) `false` comes at once, while sends are still held as the turn winds down: until its stored end or its stream's close, at most 5 s |
|
|
129
|
+
| `onBeforeSend` | `(draft) => false \| { text?, displayText?, extras? } \| void` | — | Sees every non-empty send first and takes it over (`false`), rewords it, shows the user's bubble other words or adds body fields (a message with fields of its own is held with `holdSendsDuringTurn`) — see [Host extensions](#host-extensions) |
|
|
130
|
+
| `hideConversationList` | `boolean` | `false` | Hides the conversation list of fullscreen and inline modes, for a rail or a task pane (usable from 320 px wide); the header's history menu stands in for it — see [Host extensions](#host-extensions) |
|
|
131
|
+
| `holdSendsDuringTurn` | `boolean` | `false` | A message that cannot steer is held and sent when the turn ends, instead of waiting in the composer. A turn winding down after a provisional end (until its stored end or its stream's close, at most 5 s) holds sends with or without it |
|
|
92
132
|
|
|
93
133
|
Plain-data props (`requestHeaders`, `apiEndpoints`, `pageContext`, `promptSuggestions`) are compared by content and callback props are called
|
|
94
134
|
through a stable wrapper, so building them inline is fine. `t` and `logoIcon` are used as given: keep them stable (`useCallback` / `useMemo`),
|
|
95
135
|
or every render of the host re-renders every message in the thread.
|
|
96
136
|
|
|
137
|
+
#### Inline mode and the handle
|
|
138
|
+
|
|
139
|
+
`mode="inline"` fills the host's container: the conversation list sits beside
|
|
140
|
+
the thread as in fullscreen, and there is no view switcher or close button.
|
|
141
|
+
With a `ref`, the host drives it:
|
|
142
|
+
|
|
143
|
+
```tsx
|
|
144
|
+
const chat = useRef<ChatPanelHandle>(null);
|
|
145
|
+
|
|
146
|
+
<div style={{ height: '100%' }}>
|
|
147
|
+
<ChatPanel
|
|
148
|
+
ref={chat}
|
|
149
|
+
mode="inline"
|
|
150
|
+
conversationId={searchParams.get('c')}
|
|
151
|
+
onConversationChange={(id) => setSearchParams(id ? { c: id } : {})}
|
|
152
|
+
storageKeyPrefix="chat-page"
|
|
153
|
+
// ...
|
|
154
|
+
/>
|
|
155
|
+
</div>;
|
|
156
|
+
|
|
157
|
+
chat.current?.newConversation({ agent: 'threat-hunter' });
|
|
158
|
+
chat.current?.send('Summarize this report', { extras: { mode: 'deep_investigation' } });
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
| Method | What it does |
|
|
162
|
+
| --- | --- |
|
|
163
|
+
| `send(text, { extras, displayText })` | Sends *text* as the user, leaving the composer's draft alone; `extras` join that one message's body, and the user's bubble shows `displayText` in place of *text* (as `onBeforeSend`'s `displayText`, which wins when it gives one). Resolves to `'sent'` (once the backend takes it), `'steered'` (joined the running turn), `'held'` (sent when the turn ends - also, with or without `holdSendsDuringTurn`, while a turn ended provisionally winds down, after `onTurnRunningChange(false)`), `'dropped'` (empty, taken over by `onBeforeSend`, `readOnly`, or the panel unmounted first) or `'failed'` (the backend refused it) |
|
|
164
|
+
| `newConversation({ workspaceId, agent })` | Starts a new chat, in a workspace and with an agent (id or slug) if given |
|
|
165
|
+
| `selectConversation(id)` | Opens a conversation |
|
|
166
|
+
| `setDraft(text)` / `focusComposer()` | Fills / focuses the composer |
|
|
167
|
+
| `getDraft()` | The composer's text as it stands (typed by the user, or set by `setDraft`), e.g. for `onBeforeSend` taking over a held message (`draft.held`) to tell whether the composer still holds what was typed for it |
|
|
168
|
+
| `stop()` | Same as the Stop button; during a voice call it also drops the question awaited and stops speaking |
|
|
169
|
+
| `startVoiceCall()` | Starts a [voice call](#voice-call) over the panel; `false` when this browser has no speech recognition or the panel is `readOnly` |
|
|
170
|
+
| `reloadAgents()` | Reads the agent list again, keeping the agent picked unless it is gone |
|
|
171
|
+
| `scrollToMessage(id)` | Scrolls to a message by its server id and highlights it; `false` when it is not on screen |
|
|
172
|
+
|
|
173
|
+
#### Host extensions
|
|
174
|
+
|
|
175
|
+
A host adds what is its own without the package learning about it. An answer
|
|
176
|
+
marker `[[KIND:args]]` renders the host's card where it stands, once the answer
|
|
177
|
+
is done (`renderWhileStreaming` to show it earlier); a renderer named `FILE`,
|
|
178
|
+
`PRESENTATION`, `REPORT` or `VIDEO` replaces the built-in card, and a marker no
|
|
179
|
+
renderer names stays text:
|
|
180
|
+
|
|
181
|
+
```tsx
|
|
182
|
+
const markers = useMemo(
|
|
183
|
+
() => ({
|
|
184
|
+
WORK_ITEM: { render: (id) => <WorkItemCard id={id} /> },
|
|
185
|
+
INVESTIGATION: { render: (id) => <InvestigationBlock id={id} />, fullWidth: true },
|
|
186
|
+
}),
|
|
187
|
+
[],
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
<ChatPanel
|
|
191
|
+
markerRenderers={markers}
|
|
192
|
+
transformMessageText={maskSecrets}
|
|
193
|
+
banner={<ChannelContinuityBanner />}
|
|
194
|
+
composerHeader={<DesktopMachineBar />}
|
|
195
|
+
renderMessageExtras={(m) => <RunnerActivity message={m} />}
|
|
196
|
+
// ...
|
|
197
|
+
/>;
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
A fenced code block whose language a host renderer names (`codeBlockRenderers`,
|
|
201
|
+
matched case-insensitively) renders that node in place of the code block -
|
|
202
|
+
a chart drawn from the block's data, for instance. The renderer gets the
|
|
203
|
+
block's text and `streaming`, true while the answer is still coming (the block
|
|
204
|
+
may then be incomplete); a language no renderer names, or a renderer that
|
|
205
|
+
returns `null` (a block it cannot read), stays a code block:
|
|
206
|
+
|
|
207
|
+
```tsx
|
|
208
|
+
const codeBlocks = useMemo(
|
|
209
|
+
() => ({
|
|
210
|
+
chart: (code: string, { streaming }: { streaming: boolean }) => (streaming ? <ChartSkeleton /> : <Chart spec={code} />),
|
|
211
|
+
}),
|
|
212
|
+
[],
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
<ChatPanel codeBlockRenderers={codeBlocks} />;
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
A card or a code block renderer that throws shows `This item could not be
|
|
219
|
+
loaded.` and leaves the rest of the answer on screen. A marker's arguments come from the model's answer:
|
|
220
|
+
treat them as untrusted input, never as markup or a URL to follow unchecked. Like `t`, the function props are used as given: keep
|
|
221
|
+
them stable, and a new one re-renders every message, which is also how to
|
|
222
|
+
refresh what they render.
|
|
223
|
+
|
|
224
|
+
`onBeforeSend(draft)` sees every non-empty send first, synchronously:
|
|
225
|
+
`draft.text`, `draft.source` (`'composer'`, `'host'` or `'voice'`) and
|
|
226
|
+
`draft.turnRunning` (a turn is running). It returns `false` to take the send
|
|
227
|
+
over (the composer keeps its draft), or an object: `text` rewords what the
|
|
228
|
+
backend receives, `displayText` is what the user's bubble shows, and `extras`
|
|
229
|
+
adds body fields. The panel's own fields cannot be set this way, and a message
|
|
230
|
+
with fields of its own never steers: it is held with `holdSendsDuringTurn`. A
|
|
231
|
+
message held while a turn ran is asked about again as it leaves as a new turn
|
|
232
|
+
(`turnRunning: false`, `held: true`), each send held with it on its own; taken
|
|
233
|
+
over then, what the user typed for it goes back to the composer, after what
|
|
234
|
+
was typed since. A held message every send of which is taken over is settled
|
|
235
|
+
`'returned'` (`'dropped'` when the user typed nothing for it).
|
|
236
|
+
|
|
237
|
+
A host that wraps what the user typed in context of its own (the open document,
|
|
238
|
+
the page) sends one text and shows another: `onBeforeSend` returns `text`, what
|
|
239
|
+
the backend receives, and `displayText`, what the user's bubble shows. A held
|
|
240
|
+
message keeps both, and a conversation read back shows what the backend stored:
|
|
241
|
+
|
|
242
|
+
```tsx
|
|
243
|
+
<ChatPanel
|
|
244
|
+
onBeforeSend={({ text }) => ({ text: `${documentContext()}\n\n${text}`, displayText: text })}
|
|
245
|
+
// ...
|
|
246
|
+
/>;
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
A host's own send does the same through the handle (the send hook's
|
|
250
|
+
`displayText` wins when it gives one):
|
|
251
|
+
|
|
252
|
+
```tsx
|
|
253
|
+
chat.current?.send(`${documentContext()}\n\n${typed}`, { displayText: typed });
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
`onTurnRunningChange(running)` tells the host when a turn starts or stops
|
|
257
|
+
running - on screen, where it shows the Stop button, the status line and the
|
|
258
|
+
waiting panel, or a relayed turn let go of while the page was hidden; the
|
|
259
|
+
state `onBeforeSend` reports as `draft.turnRunning` - so it never re-derives
|
|
260
|
+
it from `onStreamEvent`. `true` comes with the panel's
|
|
261
|
+
own send and with a turn the [event stream](#turns-that-outlive-their-stream)
|
|
262
|
+
relays; `false` once that turn ends: its stream closes after the last `done`
|
|
263
|
+
(an intermediate `done` goes on), an `error`, `cancelled` or `session_expired`
|
|
264
|
+
frame comes, Stop lets go of it, a new conversation replaces it, or the panel
|
|
265
|
+
unmounts in the middle of it. A turn handed over to the event stream stays
|
|
266
|
+
running meanwhile, and so does a relayed turn let go of while the page was
|
|
267
|
+
hidden, until the reopened stream replays it or the conversation read back
|
|
268
|
+
says how it ended. It is called on changes only.
|
|
269
|
+
|
|
270
|
+
With `false` comes how the turn ended, `info.ended` (`ChatTurnOutcome`), so a
|
|
271
|
+
host running a sequence of turns goes on only while they are answered:
|
|
272
|
+
|
|
273
|
+
- `'answered'`: its last `done` came, with nothing after it saying otherwise;
|
|
274
|
+
for a relayed turn that ended out of sight (nothing replayed it, or the event
|
|
275
|
+
stream could not reopen), the conversation read back holds an answer stored
|
|
276
|
+
after what was shown (an `error` or a `cancelled` the backend stores as an
|
|
277
|
+
answer reads the same);
|
|
278
|
+
- `'stopped'`: a `cancelled` frame (even after the last `done`), a
|
|
279
|
+
`session_expired` frame, Stop, a new conversation, the panel unmounting, a
|
|
280
|
+
stream that closed before its last `done` with no event stream to carry the
|
|
281
|
+
turn to its end (a turn the event stream does carry ends as its replay does),
|
|
282
|
+
or a relayed turn that ended out of sight with no answer read back (none
|
|
283
|
+
stored, or the conversation could not be read back);
|
|
284
|
+
- `'failed'`: an `error` frame, or a send the backend refused or could not be
|
|
285
|
+
reached for.
|
|
286
|
+
|
|
287
|
+
```tsx
|
|
288
|
+
<ChatPanel
|
|
289
|
+
onTurnRunningChange={(running, info) => {
|
|
290
|
+
setAgentWorking(running);
|
|
291
|
+
if (!running && info?.ended !== 'answered') cancelSequence();
|
|
292
|
+
}}
|
|
293
|
+
/>
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Each answer says which agent wrote it, for a host's own rendering
|
|
297
|
+
(`renderMessageExtras`, `messageFooterActions`): `message.answeredBy`
|
|
298
|
+
(`{ agentId, agentName, agentIcon }`, from `answered_by`) and
|
|
299
|
+
`message.mentionFailed` (`mention_failed`: a note written in place of a
|
|
300
|
+
mentioned agent that could not answer), read from every `done` frame,
|
|
301
|
+
intermediate ones included, and from each restored entry.
|
|
302
|
+
|
|
303
|
+
Both slots get the message and `{ streaming }`, true while it is the answer
|
|
304
|
+
still being written (`messageFooterActions` shows on a finished answer only,
|
|
305
|
+
so it is always false there). `message.raw` is the backend's own object for
|
|
306
|
+
the message, a plain copy the panel reads nothing from: the restored entry
|
|
307
|
+
(paged or relayed ones too), or the `done` frame that closed a live answer
|
|
308
|
+
(intermediate ones included) and, after a provisional end, the stored end. A
|
|
309
|
+
host reads its backend's fields there (a trace id, a channel, a format)
|
|
310
|
+
without fetching the conversation again; their shape is the backend's to
|
|
311
|
+
define. A message no backend object describes yet (one being written, a user
|
|
312
|
+
message sent from this panel) has none.
|
|
313
|
+
|
|
314
|
+
```tsx
|
|
315
|
+
<ChatPanel
|
|
316
|
+
renderMessageExtras={(m, { streaming }) => (streaming ? null : <TraceLink id={m.raw?.llm_trace_id as string | undefined} />)}
|
|
317
|
+
/>
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
`onStreamEvent(frame, info)` gives every frame as received, with what the
|
|
321
|
+
panel read in it, so a host never re-derives either: `info.relayed` (a turn
|
|
322
|
+
the event stream relays), `info.end` - `'provisional'` on an `error` or
|
|
323
|
+
`cancelled` marked `provisional: true` (its stored end follows),
|
|
324
|
+
`'stored'` on an end naming the stored answer's `message_id` (a `done` that is
|
|
325
|
+
not intermediate, a `cancelled`, an `error`) - and `info.replayed` on the
|
|
326
|
+
`thinking` frame (`resumed: true`) of a turn replayed from its start, whose
|
|
327
|
+
frames then restate it so far.
|
|
328
|
+
|
|
329
|
+
`onSessionEnded(reason)` says the user's session ended, as the backend tells
|
|
330
|
+
it: a `session_expired` frame on either stream, or a `401` whose body's
|
|
331
|
+
`detail.code` is `session_expired` (`{"detail": {"code": "session_expired",
|
|
332
|
+
"reason": "idle"}}`) on any request the panel makes. *reason* is the
|
|
333
|
+
frame's or the `detail`'s `reason`, `undefined` when it gives none. It comes
|
|
334
|
+
once, whichever request or stream said it first, and again only once
|
|
335
|
+
`requestHeaders` change. Signing the user out is the host's: the panel ends
|
|
336
|
+
the turn on screen, and a refused read-back offers Retry.
|
|
337
|
+
|
|
338
|
+
`hideConversationList` gives the thread the panel's whole width in inline (or
|
|
339
|
+
fullscreen) mode, for a rail beside the host's own content or an add-in's task
|
|
340
|
+
pane: the header, thread and composer hold from 320 px wide. The history is
|
|
341
|
+
still there, in the header's history menu (open, delete, Clear all), and a
|
|
342
|
+
referenced conversation's chip still opens it; the `sidebar*` slots render
|
|
343
|
+
nothing meanwhile. To drop the history altogether, set `apiEndpoints.history`
|
|
344
|
+
to `null` instead.
|
|
345
|
+
|
|
346
|
+
#### Voice call
|
|
347
|
+
|
|
348
|
+
`startVoiceCall()` on the handle opens a call over the panel, for a host's own
|
|
349
|
+
button (`voiceCallSupported()` says whether the browser can hold one): the
|
|
350
|
+
browser's speech recognition listens in `locale`, a phrase is sent after one
|
|
351
|
+
second of silence (as the user, `onBeforeSend` seeing `source: 'voice'`), and
|
|
352
|
+
the answer is said through `apiEndpoints.tts` in pieces of about 200
|
|
353
|
+
characters, played in order, the next one requested while one plays, each
|
|
354
|
+
piece the route fails or the browser cannot play (or every piece, without the
|
|
355
|
+
route) in the browser's voice. Then it listens again. The thread under the
|
|
356
|
+
call shows the conversation as usual; a `transformMessageText` that throws
|
|
357
|
+
leaves the answer said and shown as it came.
|
|
358
|
+
|
|
359
|
+
- Only the answer to the question is said: the final `done` of the panel's own
|
|
360
|
+
turn, or of the turn a `folded_into_active_turn` frame (or the steer
|
|
361
|
+
route's `turn_id`) names, never a turn of another tab and never a mid-turn
|
|
362
|
+
segment on its own. A steer stored that the backend says no turn took
|
|
363
|
+
(`"answered": false`) gets no answer, and the call listens again. Only the
|
|
364
|
+
addressed agent (the conversation's, or the one a `transfer_start` hands it
|
|
365
|
+
to) speaks; replies the turn's other agents give (`answered_by`) show by
|
|
366
|
+
name, `Did not answer` beside one that carries `mention_failed`.
|
|
367
|
+
- Speech over the answer (barge-in), a tap on the call or Skip stops it and
|
|
368
|
+
listens; a tap while the answer is awaited cancels the question and stops
|
|
369
|
+
its turn, and so does the handle's `stop()`. The microphone can be muted;
|
|
370
|
+
the call mutes it itself when the recogniser fails five times in a row with
|
|
371
|
+
nothing heard between (a refused or missing microphone, no network, a
|
|
372
|
+
language it lacks), silence (`no-speech`) and an aborted session aside.
|
|
373
|
+
- A question held while a turn runs (`holdSendsDuringTurn`) is awaited once
|
|
374
|
+
it goes out; one that never does (`onBeforeSend` takes it over as it
|
|
375
|
+
leaves, Stop, a failed send) gets no answer, and the call listens again.
|
|
376
|
+
- A turn silent for 120 s is checked through `apiEndpoints.pendingApprovals`
|
|
377
|
+
(its `turn: 'running'`): still running, the call waits on; over (or with no
|
|
378
|
+
such route), it gives up on that question and listens again.
|
|
379
|
+
|
|
97
380
|
#### Resizable Sidebar Example
|
|
98
381
|
|
|
99
382
|
```tsx
|
|
@@ -117,7 +400,7 @@ function App() {
|
|
|
117
400
|
}
|
|
118
401
|
```
|
|
119
402
|
|
|
120
|
-
The sidebar width is persisted to `localStorage` under the key `filigranChatSidebarWidth
|
|
403
|
+
The sidebar width is persisted to `localStorage` under the key `filigranChatSidebarWidth` (`<storageKeyPrefix>:filigranChatSidebarWidth` with a prefix).
|
|
121
404
|
|
|
122
405
|
### `ChatToggleButton`
|
|
123
406
|
|
|
@@ -134,7 +417,8 @@ import { ChatToggleButton } from '@filigran/chatbot';
|
|
|
134
417
|
| `isOpen` | `boolean` | **required** | Whether the chat panel is open |
|
|
135
418
|
| `onToggle` | `() => void` | **required** | Called when button is clicked |
|
|
136
419
|
| `label` | `string` | built-in | Button text, already translated by the host; omit for `t('Ask Assistant')` |
|
|
137
|
-
| `t` | `(key: string) => string` |
|
|
420
|
+
| `t` | `(key: string) => string` | — | The host's translations; the panel's catalog in `locale` answers the rest |
|
|
421
|
+
| `locale` | `string` | browser's | The default label's language, by the panel's rule (see Internationalization) |
|
|
138
422
|
| `accentColor` | `string` | `'#7b5cff'` | Button background color |
|
|
139
423
|
| `icon` | `React.ReactNode` | default icon | Custom icon |
|
|
140
424
|
|
|
@@ -189,7 +473,11 @@ Restores conversation history (and, implicitly, resolves the session).
|
|
|
189
473
|
}
|
|
190
474
|
```
|
|
191
475
|
|
|
192
|
-
`id`, `created_at` and `feedback` are optional
|
|
476
|
+
`id`, `created_at` and `feedback` are optional, and so are `answered_by`
|
|
477
|
+
(`{ agent_id, agent_name, agent_icon }`, the agent that wrote an answer of a
|
|
478
|
+
turn several agents answered) and `mention_failed` (`true` on a note written
|
|
479
|
+
in place of an agent that could not answer), handed to the host as
|
|
480
|
+
`message.answeredBy` and `message.mentionFailed`. `id` is the message's
|
|
193
481
|
persisted id, what its rating is stored against (see
|
|
194
482
|
[Message feedback](#message-feedback));
|
|
195
483
|
`created_at` (ISO 8601) is when it was sent, shown in its footer - a message
|
|
@@ -206,7 +494,12 @@ empty `messages` array. The component adopts whatever id the response returns
|
|
|
206
494
|
against a dead conversation — which would otherwise fail with a
|
|
207
495
|
"conversation does not exist" error and force the user to manually start a new
|
|
208
496
|
conversation. If the endpoint responds with a non-2xx status, the component
|
|
209
|
-
silently resets the stored id and starts fresh on the next message
|
|
497
|
+
silently resets the stored id and starts fresh on the next message, except
|
|
498
|
+
when it says nothing of the conversation: a `502`, `503` or `504` (or no
|
|
499
|
+
answer at all) keeps the id and reads it back once the backend answers, and a
|
|
500
|
+
`401` or `403` (expired credentials) keeps it, shows the response's `detail`
|
|
501
|
+
(or "This conversation could not be loaded.") with a Retry button, and reads
|
|
502
|
+
it back as soon as `requestHeaders` change.
|
|
210
503
|
|
|
211
504
|
Assistant history messages **should echo the same `attachments[]` array** that
|
|
212
505
|
was sent on the original `done` event (see [Agent-generated file attachments](#agent-generated-file-attachments)),
|
|
@@ -362,6 +655,12 @@ host and can be extended later (page title, selected entity, user role, etc.).
|
|
|
362
655
|
It must be JSON-serializable — a non-serializable value (circular reference,
|
|
363
656
|
`BigInt`, …) is skipped rather than breaking the request.
|
|
364
657
|
|
|
658
|
+
A refusal before the stream (a non-2xx answer: a rate limit, a refused
|
|
659
|
+
option, an unknown conversation) shows the response's `detail` in place of the
|
|
660
|
+
answer - a string, or the `message` (or `detail`) of an object - and
|
|
661
|
+
"Unable to connect. Please check the configuration." when it has none; the
|
|
662
|
+
handle's `send` resolves `'failed'`.
|
|
663
|
+
|
|
365
664
|
**Response:** Server-Sent Events stream with these event types:
|
|
366
665
|
|
|
367
666
|
```
|
|
@@ -374,6 +673,11 @@ data: {"type": "stream", "content": "today is sunny."}
|
|
|
374
673
|
data: {"type": "done", "content": "The weather today is sunny.", "conversation_id": "new-uuid", "message_id": "msg-2", "tool_names": ["search_web"], "tool_call_count": 1, "iterations": 1, "reasoning": "Let me check the weather data first.", "tool_call_trace": [{"name": "search_web", "input": "{\"query\": \"weather\"}", "output": "Sunny, 24C", "success": true}], "transfer_chain": [{"agent_id": "uuid", "agent_name": "General"}], "is_truncated": false}
|
|
375
674
|
```
|
|
376
675
|
|
|
676
|
+
A `done` may carry `answered_by` and `mention_failed`, as a restored entry
|
|
677
|
+
does, and a `{"type": "session_expired", "reason": "idle"}` frame (the user's
|
|
678
|
+
session ended, the stream ends with it) ends the turn on screen there and
|
|
679
|
+
then, keeping what it wrote; it does the same on the event stream.
|
|
680
|
+
|
|
377
681
|
The optional `message_id` on `done` is the answer's persisted id: without it
|
|
378
682
|
(and without a restore naming the message) the answer cannot be rated through
|
|
379
683
|
`apiEndpoints.feedback`.
|
|
@@ -441,6 +745,7 @@ Download failures (403/404/5xx/network) are reported through the optional `onDow
|
|
|
441
745
|
- `thinking` — Agent is processing
|
|
442
746
|
- `tool_start` — Agent is using tools (with `tools` array)
|
|
443
747
|
- `tool_heartbeat` — Liveness signal during a long tool execution (with `tools` and `elapsed_s`). The widget keeps the current status label and renders a live elapsed-time indicator next to it once the execution exceeds ~15 s, so long operations (background tasks, agent consults) never look stuck
|
|
748
|
+
- `wait_start` — The agent pauses (with `seconds` and `reason`): the reason and a countdown show, with `Continue now` when the host names `skipWait`. The pause stays on screen through `wait_status` (what it watches changed state), `tool_heartbeat`, `tool_progress` and `consult_progress`; streamed text or any other status ends it
|
|
444
749
|
- `analyzing` — Agent is analyzing tool results
|
|
445
750
|
- `composing` — Agent is composing the response
|
|
446
751
|
- `streaming` — Content is being streamed
|
|
@@ -452,6 +757,10 @@ Download failures (403/404/5xx/network) are reported through the optional `onDow
|
|
|
452
757
|
data: {"type": "error", "content": "Something went wrong"}
|
|
453
758
|
```
|
|
454
759
|
|
|
760
|
+
An `error` ends the turn for good, and so does a `cancelled`, unless marked
|
|
761
|
+
`provisional: true` (see
|
|
762
|
+
[Turns that outlive their stream](#turns-that-outlive-their-stream)).
|
|
763
|
+
|
|
455
764
|
### `POST {apiBaseUrl}/chat/messages/steer`
|
|
456
765
|
|
|
457
766
|
Steers the agent mid-run: while a response is still streaming, Enter / the
|
|
@@ -462,22 +771,44 @@ until the turn finishes. The widget POSTs:
|
|
|
462
771
|
{
|
|
463
772
|
"conversation_id": "uuid-here",
|
|
464
773
|
"content": "Actually, filter by subregion instead",
|
|
465
|
-
"agent_slug": "general"
|
|
774
|
+
"agent_slug": "general",
|
|
775
|
+
"file_ids": ["file-uuid"]
|
|
466
776
|
}
|
|
467
777
|
```
|
|
468
778
|
|
|
779
|
+
`file_ids` is sent only with `steerAttachments` set, for files already
|
|
780
|
+
uploaded: by default a message with attachments does not steer.
|
|
781
|
+
|
|
469
782
|
A 2xx response means the message was persisted and will be injected into the
|
|
470
|
-
running agentic loop at the next iteration boundary
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
783
|
+
running agentic loop at the next iteration boundary (`turn_id`, when the body
|
|
784
|
+
names it, is the turn it joined), whatever else the body says: XTM One's
|
|
785
|
+
`{"status": "stored", "turn_id": null}` (stored in the thread, handed to no turn
|
|
786
|
+
it can name) keeps the bubble and resolves `'steered'` too. With
|
|
787
|
+
`"answered": false` the backend knows no turn took it: the bubble stays and a
|
|
788
|
+
notice above the composer says the message will be read with the next one,
|
|
789
|
+
until the next send. On a non-2xx
|
|
790
|
+
response or a network error nothing was stored: the optimistic user bubble is
|
|
791
|
+
rolled back, the text is restored into the composer (prepended on its own line
|
|
792
|
+
if the user already typed something new), the send resolves `'failed'` and the
|
|
793
|
+
refusal's `detail`, when there is one, shows above the composer — so a backend
|
|
794
|
+
without steering support degrades gracefully and the message is never silently
|
|
795
|
+
lost. A `409` means no turn is running any more, an ordinary race: no notice
|
|
796
|
+
shows, and with `holdSendsDuringTurn` the message is held and sent as the next
|
|
797
|
+
turn instead of going back to the composer. Set `apiEndpoints.steer` to `null`
|
|
798
|
+
to disable the steering affordances entirely.
|
|
799
|
+
|
|
800
|
+
Steering only applies to sends on the `rest` backend with a known
|
|
478
801
|
`conversation_id` (the first turn of a fresh conversation only receives its id
|
|
479
|
-
on `done`).
|
|
480
|
-
|
|
802
|
+
on `done`). A message with attachments steers only with `steerAttachments`
|
|
803
|
+
(once every file is uploaded); one carrying conversation references, agent
|
|
804
|
+
mentions or body fields of its own (`send`'s `extras`, `onBeforeSend`'s
|
|
805
|
+
`extras`) never steers, since a steer reaches the agent as text alone: it waits
|
|
806
|
+
for the turn to end (`holdSendsDuringTurn`), and `onBeforeSend` is asked again,
|
|
807
|
+
with `turnRunning: false`, before it goes out. A file that failed to upload
|
|
808
|
+
neither steers nor waits: while a turn runs, winds down or is let go of with
|
|
809
|
+
the event stream, the composer offers no send and says why, and once no turn
|
|
810
|
+
holds the conversation the message goes without that file. `send` and a voice
|
|
811
|
+
call attach no files, so this never holds them. Esc stops generating.
|
|
481
812
|
|
|
482
813
|
#### Multi-segment responses
|
|
483
814
|
|
|
@@ -572,9 +903,8 @@ On a non-2xx the prompt stays on screen with the failure noted and the controls
|
|
|
572
903
|
re-armed: the turn is still paused either way, so clearing the prompt would
|
|
573
904
|
strand it with nothing able to answer. A `409` means nothing is waiting any more
|
|
574
905
|
(the turn finished, was cancelled, or was answered elsewhere); the stream ending
|
|
575
|
-
then clears the prompt on its own. Stopping the turn also dismisses it
|
|
576
|
-
|
|
577
|
-
only other way out.
|
|
906
|
+
then clears the prompt on its own. Stopping the turn also dismisses it (and,
|
|
907
|
+
with `cancel`, asks the backend to end it).
|
|
578
908
|
|
|
579
909
|
**Proxied hosts:** the decision goes through the same fetch path as every other
|
|
580
910
|
endpoint — relative to `apiBaseUrl` and honouring `requestHeaders` — so CSRF
|
|
@@ -677,6 +1007,94 @@ abandonment, never the person deciding.
|
|
|
677
1007
|
|
|
678
1008
|
REST backend only: `legacy` and `ag-ui` never meet this gate.
|
|
679
1009
|
|
|
1010
|
+
### Turns that outlive their stream
|
|
1011
|
+
|
|
1012
|
+
A backend whose turns keep running when their stream drops (XTM One's own web
|
|
1013
|
+
chat) opts into six more routes, none with a default:
|
|
1014
|
+
|
|
1015
|
+
| `apiEndpoints` key | Request | What it does |
|
|
1016
|
+
| --- | --- | --- |
|
|
1017
|
+
| `cancel` | `POST {cancel}` with `{ conversation_id }` | Stop asks the backend to end the turn; the panel keeps reading until the stream's `cancelled` frame (or lets go after 10 s, or at once on a network error or an answer of `{ "turn": "none" }`, reading the conversation back for an end it had not read yet). A Stop pressed before the panel's own send started its turn (its stream delivered nothing yet) carries `"awaiting_send": true`, and its `none` says nothing of that turn: the Stop stays pending and is sent again, without the flag, as the turn starts (or as the answer comes, the turn started meanwhile); any other answer reached a turn, and nothing goes again. A backend may keep a Stop with that flag for the send's turn, and must then take a second cancel of one turn; a Stop without it says nothing of a later send. A refusal (non-2xx) keeps the turn running and shows its `detail` above the composer |
|
|
1018
|
+
| `skipWait` | `POST {skipWait}` with `{ conversation_id }` | `Continue now` on `status: wait_start` (`{ seconds, reason }`), for as long as the pause lasts (see the status values) |
|
|
1019
|
+
| `events` | `GET {events}/{conversation_id}/events?client_id=<id>` → SSE | Turns this panel did not start (another tab, a reload mid-turn), `user_message`, `conversation_title`, `approval_resolved`; every frame of a turn names it (`turn_id`, below) |
|
|
1020
|
+
| `messagesPage` | `GET {messagesPage}/{conversation_id}/messages?before=<message id>&limit=50&supports_artifacts=true` → `{ messages, has_more }` | "Load earlier messages" past the restored ones, offered when the restore answers `has_more: true` |
|
|
1021
|
+
| `clearAll` | `DELETE {clearAll}` | "Clear all conversations" in the conversation list and the header's history menu, confirmed first |
|
|
1022
|
+
| `tts` | `POST {tts}` with `{ text, format: 'mp3' }` → audio | Read aloud in the backend's voice; the browser's when it fails |
|
|
1023
|
+
|
|
1024
|
+
The event stream is opened once the conversation's history is read back (not
|
|
1025
|
+
while it could not be, for want of an agent or a reachable backend) and
|
|
1026
|
+
reconnected with backoff while the page is visible. Once the page has been
|
|
1027
|
+
hidden for 60 s it is closed (under HTTP/1.1 a background tab would otherwise
|
|
1028
|
+
hold one of the browser's six connections to the origin), and the relayed turn
|
|
1029
|
+
it fed is let go of: the segment it was writing goes, those it closed stay.
|
|
1030
|
+
Shown again, the stream reopens, the backend's snapshot replays a turn still
|
|
1031
|
+
running, and the conversation is read back for what ended meanwhile. The turn
|
|
1032
|
+
let go of may run on: sends wait for it (as while a turn winds down) and
|
|
1033
|
+
`onTurnRunningChange` says nothing until the reopened stream replays it or
|
|
1034
|
+
shows another turn. Not replayed within 5 s of the page showing again, or
|
|
1035
|
+
with the stream refused, it ended: the conversation is read back, and the
|
|
1036
|
+
turn ends as it says (`info.ended`), 10 s at most after. The backend may end
|
|
1037
|
+
the event stream at any time; a stream it ends, or that drops, is reopened,
|
|
1038
|
+
from the shortest delay again once it had delivered a frame or lived 5 s (one
|
|
1039
|
+
that closes at once backs off); a relayed turn on screen then runs on only if
|
|
1040
|
+
the snapshot replays it within 5 s, or ends as the conversation read back
|
|
1041
|
+
says.
|
|
1042
|
+
A `404` or `405` stops it, and a `401` or `403` stops it until
|
|
1043
|
+
`requestHeaders` change, when it is tried once more (a refreshed token picks
|
|
1044
|
+
it up).
|
|
1045
|
+
|
|
1046
|
+
With `events` named, every frame of a turn names it with the same `turn_id`
|
|
1047
|
+
wherever it goes: each turn frame of the event stream (`thinking`, `status`,
|
|
1048
|
+
`stream`, `done`, `error`, `cancelled`, `approval_required`, and the replay of
|
|
1049
|
+
a turn), and each frame of the message stream from its `turn_started` (or
|
|
1050
|
+
`folded_into_active_turn`) on. A turn frame of the event stream naming none is
|
|
1051
|
+
not read, and the console warns of it once per frame type. A backend without
|
|
1052
|
+
`events` need name no turn: each turn is then the one its own stream carries.
|
|
1053
|
+
|
|
1054
|
+
With `events` named, every message, steer, decision, page-action answer, Stop
|
|
1055
|
+
and "Continue now" carries the same id in `X-Chat-Client-Id` (the
|
|
1056
|
+
pending-approval reads and the history do not), which the backend uses to
|
|
1057
|
+
never relay a panel's own turn back to it; frames naming the `turn_id` of the
|
|
1058
|
+
panel's own turn are dropped too. When the panel's own stream drops mid-turn,
|
|
1059
|
+
or ends without the turn's last frame (`done`, `error`, `session_expired` or
|
|
1060
|
+
`cancelled`), it takes a new id and picks the turn up from the event stream,
|
|
1061
|
+
keeping the segments it closed (or reads the history back when nothing comes
|
|
1062
|
+
within 15 s). A read-back that waited for a turn to end joins the thread in
|
|
1063
|
+
place: each stored message missing from the screen goes after the one stored
|
|
1064
|
+
before it, so nothing lands above messages it follows.
|
|
1065
|
+
|
|
1066
|
+
A relayed turn uses the frames of `POST /chat/messages`, plus:
|
|
1067
|
+
|
|
1068
|
+
- `{"type": "thinking", "resumed": true, "elapsed_s": 42}` — a turn re-attached
|
|
1069
|
+
mid-way: the segment being written follows, whole (XTM One's snapshot holds
|
|
1070
|
+
that one only); the segments an intermediate `done` closed stay on screen.
|
|
1071
|
+
Replaying a turn it lost sight of (a relayed turn on a reopened event
|
|
1072
|
+
stream, its own turn after its stream dropped, a relayed turn let go of
|
|
1073
|
+
while the page was hidden), the panel reads the conversation back as the
|
|
1074
|
+
turn runs on: the segments closed meanwhile (stored by then) take their
|
|
1075
|
+
place before the one being written, matched to those on screen by
|
|
1076
|
+
`message_id`, else by text, so none shows twice; a bubble matched keeps
|
|
1077
|
+
what it shows (a user bubble's `displayText`, its files) and takes the
|
|
1078
|
+
stored id. A replay naming another turn means the one on screen ended
|
|
1079
|
+
meanwhile: the answer it was writing goes, its stored copy read back.
|
|
1080
|
+
- `{"type": "done", "intermediate": true, …}` — a segment closed, the turn goes on.
|
|
1081
|
+
- A provisional end — an `error` or `cancelled` marked `provisional: true` (a
|
|
1082
|
+
Stop's acknowledgement, a failed loop; any other is final, and nothing of
|
|
1083
|
+
its turn is awaited after it) — ends the turn on screen at once; the stored
|
|
1084
|
+
end of the same turn that follows it (a `done`, `cancelled` or `error`
|
|
1085
|
+
carrying `message_id`) only gives the answer its id, and a `done` its stored
|
|
1086
|
+
text and all a live `done` carries (attachments, reasoning, tool trace, the
|
|
1087
|
+
agent that answered, context usage, a transfer); also while the panel's own
|
|
1088
|
+
turn runs, whose context usage and agent then stay. The panel's own stream
|
|
1089
|
+
is read on for it. The backend still holds the conversation until then, so
|
|
1090
|
+
the panel holds sends meanwhile, as during a turn (with or without
|
|
1091
|
+
`holdSendsDuringTurn`): until that stored end or the stream's close (of
|
|
1092
|
+
the panel's own stream, for its own turn; another turn starting ends it
|
|
1093
|
+
too), at most 5 s. A relayed turn winds down across the event stream's
|
|
1094
|
+
reopening, whose replay of it holds sends too.
|
|
1095
|
+
- `{"type": "folded_into_active_turn", "turn_id": "…"}` (message stream) — the
|
|
1096
|
+
message joined a turn already running, whose frames follow.
|
|
1097
|
+
|
|
680
1098
|
### Decks, reports and videos
|
|
681
1099
|
|
|
682
1100
|
An answer that made a deck, a report or a video carries a marker where it shows
|
|
@@ -755,8 +1173,8 @@ persisted id — the `done` event's `message_id`, or the restored entry's
|
|
|
755
1173
|
REST backend only, and not in `singleEndpoint` mode (there is no per-path
|
|
756
1174
|
routing to name a feedback route with).
|
|
757
1175
|
|
|
758
|
-
**Read aloud** uses the browser's own speech synthesis —
|
|
759
|
-
where
|
|
1176
|
+
**Read aloud** uses the browser's own speech synthesis — or the backend's
|
|
1177
|
+
voice when the host names `apiEndpoints.tts` — hidden where neither exists, and absent on an answer with nothing to say (code only). It
|
|
760
1178
|
reads the answer's text without its markdown or file markers, in the `locale`
|
|
761
1179
|
prop's language, one answer at a time; switching conversation, sending a
|
|
762
1180
|
message or leaving the panel stops it.
|
|
@@ -794,25 +1212,39 @@ import { MyLogo } from './icons';
|
|
|
794
1212
|
|
|
795
1213
|
### Internationalization
|
|
796
1214
|
|
|
1215
|
+
The panel speaks English, French, Spanish and Japanese on its own: it ships a
|
|
1216
|
+
catalog for each and picks one from `locale` (`fr-FR` reads French; English, or
|
|
1217
|
+
a language it lacks, is its own keys). A host in another language translates
|
|
1218
|
+
through `t`. A host with nothing to add only passes `locale`:
|
|
1219
|
+
|
|
1220
|
+
```tsx
|
|
1221
|
+
<ChatPanel locale={user.language} /* ... */ />
|
|
1222
|
+
```
|
|
1223
|
+
|
|
1224
|
+
`t` is an optional override, asked first for every key — key in, translated
|
|
1225
|
+
string out, so any i18n library works. Where it returns something other than
|
|
1226
|
+
the key, its wording wins; where it returns the key (a host catalog that does
|
|
1227
|
+
not have it), the panel's own catalog answers, in `locale`'s language:
|
|
1228
|
+
|
|
797
1229
|
```tsx
|
|
798
1230
|
import { useTranslation } from 'react-i18next';
|
|
799
1231
|
|
|
800
1232
|
function App() {
|
|
801
|
-
const { t } = useTranslation();
|
|
802
|
-
|
|
803
|
-
return (
|
|
804
|
-
<ChatPanel
|
|
805
|
-
t={t}
|
|
806
|
-
// ...
|
|
807
|
-
/>
|
|
808
|
-
);
|
|
1233
|
+
const { t, i18n } = useTranslation();
|
|
1234
|
+
return <ChatPanel t={t} locale={i18n.language} /* ... */ />;
|
|
809
1235
|
}
|
|
810
1236
|
```
|
|
811
1237
|
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
1238
|
+
Which catalog answers, by what the host passes:
|
|
1239
|
+
|
|
1240
|
+
| `t` | `locale` | A key `t` does not translate shows in |
|
|
1241
|
+
| --- | --- | --- |
|
|
1242
|
+
| — | given | the panel's catalog for `locale` (English for a language it lacks) |
|
|
1243
|
+
| — | — | the panel's catalog for the browser's language |
|
|
1244
|
+
| given | given | the panel's catalog for `locale` |
|
|
1245
|
+
| given | — | English: the panel's catalog is not used |
|
|
1246
|
+
|
|
1247
|
+
`ChatToggleButton` takes the same `t` and `locale` for its default label, by the same rule.
|
|
816
1248
|
|
|
817
1249
|
#### Values inside sentences
|
|
818
1250
|
|
|
@@ -827,7 +1259,7 @@ value, and a translator sees the sentence rather than a fragment of one:
|
|
|
827
1259
|
| `'{percent}% full'` | 84% full |
|
|
828
1260
|
| `'How can {agent} help you, {name}?'` | How can **Threat Analyst** help you, John? |
|
|
829
1261
|
|
|
830
|
-
Two consequences worth knowing when writing
|
|
1262
|
+
Two consequences worth knowing when writing a translation:
|
|
831
1263
|
|
|
832
1264
|
- **Plurals are separate keys** — `'1 tool call'` and `'{count} tool calls'`,
|
|
833
1265
|
`'Delegating one task…'` and `'Delegating {count} tasks…'`. A lookup cannot
|
|
@@ -843,236 +1275,12 @@ Two consequences worth knowing when writing the locale files:
|
|
|
843
1275
|
`promptSuggestions` (and any suggestions the backend serves) also go through
|
|
844
1276
|
`t`, so a host may pass either keys or final text.
|
|
845
1277
|
|
|
846
|
-
#### Translation keys
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
- `'Ask Assistant'`
|
|
853
|
-
|
|
854
|
-
**Header, agent menu and history**
|
|
855
|
-
|
|
856
|
-
- `'Agent switching is not available here'`
|
|
857
|
-
- `'Browse agents'`
|
|
858
|
-
- `'Close'`
|
|
859
|
-
- `'Conversation history'`
|
|
860
|
-
- `'Could not reach the assistant service. Check the connection and try again.'`
|
|
861
|
-
- `'Create agent'`
|
|
862
|
-
- `'Delete conversation'`
|
|
863
|
-
- `'Floating'`
|
|
864
|
-
- `'Full screen'`
|
|
865
|
-
- `'New chat'`
|
|
866
|
-
- `'New conversation'`
|
|
867
|
-
- `'No agent matches'`
|
|
868
|
-
- `'No conversations yet'`
|
|
869
|
-
- `'Search agents...'`
|
|
870
|
-
- `'Sidebar'`
|
|
871
|
-
- `'Switch to'`
|
|
872
|
-
- `'Switch to another agent'`
|
|
873
|
-
- `'Switch view'`
|
|
874
|
-
- `'Transferred from {agent}'`
|
|
875
|
-
- `'Untitled conversation'`
|
|
876
|
-
|
|
877
|
-
**Conversation sidebar**
|
|
878
|
-
|
|
879
|
-
- `'Conversation title'`
|
|
880
|
-
- `'Hide conversations'`
|
|
881
|
-
- `'No conversation matches'`
|
|
882
|
-
- `'Rename conversation'`
|
|
883
|
-
- `'Search conversations...'`
|
|
884
|
-
- `'Show conversations'`
|
|
885
|
-
|
|
886
|
-
**Welcome screen**
|
|
887
|
-
|
|
888
|
-
- `'Assistant'`
|
|
889
|
-
- `'Help me create a new simulation scenario'`
|
|
890
|
-
- `'How can I help you, {name}?'`
|
|
891
|
-
- `'How can {agent} help you, {name}?'`
|
|
892
|
-
- `'How do I configure detection rules?'`
|
|
893
|
-
- `'Suggestions'`
|
|
894
|
-
- `'Summarize my recent findings'`
|
|
895
|
-
- `'What are the latest attack patterns?'`
|
|
896
|
-
|
|
897
|
-
**Composer**
|
|
898
|
-
|
|
899
|
-
- `'A message references at most {count} conversations'`
|
|
900
|
-
- `'Ask a question...'`
|
|
901
|
-
- `'Attachments wait for the current response'`
|
|
902
|
-
- `'Dictate a message'`
|
|
903
|
-
- `'Enter to send now · Esc to stop'`
|
|
904
|
-
- `'Files uploading...'`
|
|
905
|
-
- `'Insert prompt template'`
|
|
906
|
-
- `'Listening...'`
|
|
907
|
-
- `'No conversation matches'`
|
|
908
|
-
- `'No prompt matches'`
|
|
909
|
-
- `'Reference a conversation'`
|
|
910
|
-
- `'Referenced conversations wait for the current response'`
|
|
911
|
-
- `'Search prompts...'`
|
|
912
|
-
- `'Send now'`
|
|
913
|
-
- `'Shared with you'`
|
|
914
|
-
- `'Stop dictation'`
|
|
915
|
-
- `'Stop generating'`
|
|
916
|
-
- `'Untitled conversation'`
|
|
917
|
-
- `'Uses AI. Verify results.'`
|
|
918
|
-
|
|
919
|
-
**Messages, markdown and files**
|
|
920
|
-
|
|
921
|
-
- `'Bad response'`
|
|
922
|
-
- `'Conversation transcript'`
|
|
923
|
-
- `'Copied'`
|
|
924
|
-
- `'Copied!'`
|
|
925
|
-
- `'Copy code'`
|
|
926
|
-
- `'Copy message'`
|
|
927
|
-
- `'Copy response'`
|
|
928
|
-
- `'Could not retract feedback.'`
|
|
929
|
-
- `'Could not submit feedback.'`
|
|
930
|
-
- `'Download'`
|
|
931
|
-
- `'Expand image'`
|
|
932
|
-
- `'Feedback already submitted'`
|
|
933
|
-
- `'Good response'`
|
|
934
|
-
- `'Image could not be loaded'`
|
|
935
|
-
- `'Image preview'`
|
|
936
|
-
- `'Load earlier messages'`
|
|
937
|
-
- `'Loading image…'`
|
|
938
|
-
- `'Open conversation'`
|
|
939
|
-
- `'Read aloud'`
|
|
940
|
-
- `'Reasoning details'`
|
|
941
|
-
- `'Reasoning details — turn limit reached'`
|
|
942
|
-
- `'Save'`
|
|
943
|
-
- `'Skip'`
|
|
944
|
-
- `'Stop reading'`
|
|
945
|
-
- `'Tell us what went wrong (optional)'`
|
|
946
|
-
- `'What could be improved?'`
|
|
947
|
-
|
|
948
|
-
**Decks, reports and videos**
|
|
949
|
-
|
|
950
|
-
- `'Download {format}'`
|
|
951
|
-
- `'Not rendered yet'`
|
|
952
|
-
- `'Open in XTM One'`
|
|
953
|
-
- `'PowerPoint'`
|
|
954
|
-
- `'PowerPoint (editable)'`
|
|
955
|
-
- `'Presentation'`
|
|
956
|
-
- `'Rendering {progress}%'`
|
|
957
|
-
- `'Report'`
|
|
958
|
-
- `'The render did not finish'`
|
|
959
|
-
- `'Video'`
|
|
960
|
-
- `'Waiting to render'`
|
|
961
|
-
- `'{count} scenes'`
|
|
962
|
-
- `'{count} sections'`
|
|
963
|
-
- `'{count} slides'`
|
|
964
|
-
|
|
965
|
-
**Agent status**
|
|
966
|
-
|
|
967
|
-
- `'Analyzing results…'`
|
|
968
|
-
- `'Collecting results from one task…'`
|
|
969
|
-
- `'Collecting results from {count} tasks…'`
|
|
970
|
-
- `'Composing answer…'`
|
|
971
|
-
- `'Consulting {agent}…'`
|
|
972
|
-
- `'Delegating one task…'`
|
|
973
|
-
- `'Delegating {count} tasks…'`
|
|
974
|
-
- `'Incorporating your message…'`
|
|
975
|
-
- `'Thinking...'`
|
|
976
|
-
- `'Transferring to {agent}…'`
|
|
977
|
-
- `'Using tools…'`
|
|
978
|
-
- `'Waiting for one background task…'`
|
|
979
|
-
- `'Waiting for your approval…'`
|
|
980
|
-
- `'Waiting for {count} background tasks…'`
|
|
981
|
-
- `'the agent'`
|
|
982
|
-
- `'{tool} (+{count} more)…'`
|
|
983
|
-
|
|
984
|
-
**Waiting panel**
|
|
985
|
-
|
|
986
|
-
- `'Almost there'`
|
|
987
|
-
- `'Analyzing the details'`
|
|
988
|
-
- `'Connecting the dots'`
|
|
989
|
-
- `'Consulting the sources'`
|
|
990
|
-
- `'Crunching the data'`
|
|
991
|
-
- `'Play Space Invaders'`
|
|
992
|
-
- `'Play Space Invaders in XTM One'`
|
|
993
|
-
- `'Polishing the answer'`
|
|
994
|
-
- `'Putting it together'`
|
|
995
|
-
- `'Reticulating splines'`
|
|
996
|
-
- `'Thinking it through'`
|
|
997
|
-
- `'Wrapping things up'`
|
|
998
|
-
|
|
999
|
-
**Reasoning details**
|
|
1000
|
-
|
|
1001
|
-
- `'(no output)'`
|
|
1002
|
-
- `'1 tool call'`
|
|
1003
|
-
- `'1 transfer'`
|
|
1004
|
-
- `'Input'`
|
|
1005
|
-
- `'Model reasoning'`
|
|
1006
|
-
- `'Output'`
|
|
1007
|
-
- `"The agent's iteration budget was exhausted - execution stopped before completing all planned steps. The final response is a best-effort summary of work done so far."`
|
|
1008
|
-
- `'Transfer chain'`
|
|
1009
|
-
- `'Turn limit reached.'`
|
|
1010
|
-
- `'{count} iterations'`
|
|
1011
|
-
- `'{count} tool calls'`
|
|
1012
|
-
- `'{count} transfers'`
|
|
1013
|
-
|
|
1014
|
-
**Tool approval**
|
|
1015
|
-
|
|
1016
|
-
- `'Also applies to your scheduled runs, until you revoke it'`
|
|
1017
|
-
- `'Always allowed'`
|
|
1018
|
-
- `'Approved'`
|
|
1019
|
-
- `'Back'`
|
|
1020
|
-
- `'Confirm'`
|
|
1021
|
-
- `'Decline this call'`
|
|
1022
|
-
- `'Declined'`
|
|
1023
|
-
- `'No'`
|
|
1024
|
-
- `'Sending…'`
|
|
1025
|
-
- `'The agent needs your approval to run a tool:'`
|
|
1026
|
-
- `'The agent needs your approval to run these tools:'`
|
|
1027
|
-
- `'Why not? The agent sees this and can adapt (optional)'`
|
|
1028
|
-
- `'Yes'`
|
|
1029
|
-
- `'Yes, always'`
|
|
1030
|
-
- `'e.g. wrong environment — use staging instead'`
|
|
1031
|
-
- `'unknown tool'`
|
|
1032
|
-
- `'{decided}/{total} decided'`
|
|
1033
|
-
- `'“Yes, always” saves a preference for you. That tool will then run without asking — including on scheduled runs nobody is watching — until you revoke it.'`
|
|
1034
|
-
|
|
1035
|
-
**Context gauge**
|
|
1036
|
-
|
|
1037
|
-
- `'Context full — older turns are being dropped'`
|
|
1038
|
-
- `'Context nearly full — older turns are being summarized'`
|
|
1039
|
-
- `'Context used'`
|
|
1040
|
-
- `'Conversation'`
|
|
1041
|
-
- `'MCP & dynamic tools'`
|
|
1042
|
-
- `'Summarized conversation'`
|
|
1043
|
-
- `'System prompt'`
|
|
1044
|
-
- `'Tool definitions'`
|
|
1045
|
-
- `'Tool results'`
|
|
1046
|
-
- `'{counts} tokens'`
|
|
1047
|
-
- `'{percent}% full'`
|
|
1048
|
-
- `'{summary} — click for details'`
|
|
1049
|
-
|
|
1050
|
-
**Quota**
|
|
1051
|
-
|
|
1052
|
-
- `'Quota'`
|
|
1053
|
-
- `'Quota reached'`
|
|
1054
|
-
- `'Usage'`
|
|
1055
|
-
- `'Usage · {period}'`
|
|
1056
|
-
- `'this month'`
|
|
1057
|
-
- `'this year'`
|
|
1058
|
-
- `'today'`
|
|
1059
|
-
- `'{quota} (shared across all users)'`
|
|
1060
|
-
|
|
1061
|
-
**Notices, errors and timestamps**
|
|
1062
|
-
|
|
1063
|
-
- `'Could not send your decision. Please try again.'`
|
|
1064
|
-
- `'No response.'`
|
|
1065
|
-
- `'Response ready'`
|
|
1066
|
-
- `'Sorry, an error occurred. Please try again.'`
|
|
1067
|
-
- `'This decision could not be sent. Reload the chat and try again.'`
|
|
1068
|
-
- `'This turn is no longer waiting for a decision.'`
|
|
1069
|
-
- `'Unable to connect. Please check the configuration.'`
|
|
1070
|
-
- `'Your answer is ready'`
|
|
1071
|
-
- `'just now'`
|
|
1072
|
-
- `'{agent} has finished'`
|
|
1073
|
-
- `'{count}d ago'`
|
|
1074
|
-
- `'{count}h ago'`
|
|
1075
|
-
- `'{count}m ago'`
|
|
1278
|
+
#### Translation keys
|
|
1279
|
+
|
|
1280
|
+
The catalogs are `src/i18n/<language>.ts`, one entry per key: the list of
|
|
1281
|
+
everything a host may override. `yarn test` fails when a key the source uses
|
|
1282
|
+
is missing from a catalog, or a translation drops one of its placeholders, so
|
|
1283
|
+
a new string ships translated with the feature that adds it.
|
|
1076
1284
|
|
|
1077
1285
|
## Styling
|
|
1078
1286
|
|
|
@@ -1087,11 +1295,15 @@ The component uses Tailwind CSS classes and CSS custom properties for theming. T
|
|
|
1087
1295
|
The stylesheet is isolated from the host page in both directions. Every utility
|
|
1088
1296
|
is scoped to the panel (`.filigran-chatbot` and its descendants) and declared
|
|
1089
1297
|
outside any cascade layer, and the theme variables (`--radius-*`, `--spacing`,
|
|
1090
|
-
...) are declared on the panel root
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
the
|
|
1298
|
+
`--color-*`, ...) are declared on the panel root only, never on `:root` or
|
|
1299
|
+
`:host`. The host's own utilities and `:root` tokens - a Tailwind build shipped
|
|
1300
|
+
without layers, such as the Filigran design system's - therefore never restyle
|
|
1301
|
+
the panel, and loading the stylesheet neither styles the host page nor
|
|
1302
|
+
redeclares the host's own variables. Panel overlays (menus, dialogs, tooltips,
|
|
1303
|
+
the image viewer) render inside the panel root, so they keep its styles; so
|
|
1304
|
+
does `ChatToggleButton`, which carries the `filigran-chatbot` class itself. A
|
|
1305
|
+
host element rendered outside the panel does not get the panel's variables:
|
|
1306
|
+
declare its own.
|
|
1095
1307
|
|
|
1096
1308
|
### Composer toolbar
|
|
1097
1309
|
|
|
@@ -1201,31 +1413,28 @@ Anything host-specific goes through `composerToolbar`:
|
|
|
1201
1413
|
A host that renders assistant prose with its **own** markdown component (its
|
|
1202
1414
|
design tokens, its icon set) should still normalise the text the same way the
|
|
1203
1415
|
panel does, rather than maintaining a divergent copy. These pure
|
|
1204
|
-
`string → string` helpers ship from the dedicated **`@filigran/chatbot/markdown`**
|
|
1205
|
-
entry point — ~
|
|
1416
|
+
`string → string` helpers ship, with the answer-marker helpers below, from the dedicated **`@filigran/chatbot/markdown`**
|
|
1417
|
+
entry point — ~5 kB, no React, no CSS. Import them from there and never from the
|
|
1206
1418
|
package root, which is the full panel bundle:
|
|
1207
1419
|
|
|
1208
1420
|
```tsx
|
|
1209
|
-
import {
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
normalizeMarkdownTables,
|
|
1214
|
-
wrapBareJson,
|
|
1215
|
-
} from '@filigran/chatbot/markdown';
|
|
1216
|
-
|
|
1217
|
-
const processed = hardenNestedCodeFences(
|
|
1218
|
-
normalizeMarkdownTables(wrapBareJson(normalizeImageMarkdown(content))),
|
|
1219
|
-
);
|
|
1421
|
+
import { markdownUrlTransform, prepareMarkdown } from '@filigran/chatbot/markdown';
|
|
1422
|
+
|
|
1423
|
+
// After the host's own rewriting, if any (its API's URLs).
|
|
1424
|
+
const processed = prepareMarkdown(rewriteOwnUrls(content));
|
|
1220
1425
|
|
|
1221
1426
|
<ReactMarkdown urlTransform={markdownUrlTransform}>{processed}</ReactMarkdown>;
|
|
1222
1427
|
```
|
|
1223
1428
|
|
|
1429
|
+
`prepareMarkdown` is the panel's own pipeline, in its order:
|
|
1430
|
+
`hardenNestedCodeFences(normalizeMarkdownTables(wrapBareJson(normalizeImageMarkdown(content))))`.
|
|
1224
1431
|
Order matters: alt-text is flattened before anything reads line structure, and
|
|
1225
|
-
the JSON wrap must see the raw payload before fences are hardened.
|
|
1432
|
+
the JSON wrap must see the raw payload before fences are hardened. Each step
|
|
1433
|
+
is exported too.
|
|
1226
1434
|
|
|
1227
1435
|
| Helper | Fixes |
|
|
1228
1436
|
| ------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
1437
|
+
| `prepareMarkdown` | All four below, in the panel's order |
|
|
1229
1438
|
| `normalizeImageMarkdown` | Multi-line `` alt text, which breaks the image into literal paragraphs plus a stray link |
|
|
1230
1439
|
| `wrapBareJson` | A whole message that is raw JSON — fenced as ```json so it stays readable and copyable |
|
|
1231
1440
|
| `normalizeMarkdownTables` | A delimiter row whose column count doesn't match the header, including tables nested in blockquotes / list items |
|
|
@@ -1235,6 +1444,49 @@ the JSON wrap must see the raw payload before fences are hardened.
|
|
|
1235
1444
|
None of them touch content they don't apply to — an already-valid document is
|
|
1236
1445
|
returned byte-identical.
|
|
1237
1446
|
|
|
1447
|
+
The same entry point reads the `[[KIND:args]]` markers of an answer exactly as
|
|
1448
|
+
the panel does, for a host that renders answers, previews or notifications of
|
|
1449
|
+
its own:
|
|
1450
|
+
|
|
1451
|
+
```ts
|
|
1452
|
+
import { markerKinds, splitAnswerMarkers, stripAnswerMarkers, type AnswerPart } from '@filigran/chatbot/markdown';
|
|
1453
|
+
|
|
1454
|
+
const kinds = markerKinds(['WORK_ITEM', 'INVESTIGATION']); // the host's own kinds
|
|
1455
|
+
const parts: AnswerPart[] = splitAnswerMarkers(content, isStreaming, kinds);
|
|
1456
|
+
const preview = stripAnswerMarkers(content, kinds);
|
|
1457
|
+
```
|
|
1458
|
+
|
|
1459
|
+
| Helper | What it does |
|
|
1460
|
+
| --- | --- |
|
|
1461
|
+
| `markerKinds(names)` | The host's marker names upper-cased, deduplicated and sorted, invalid ones (anything but `A-Z`, `0-9`, `_`, starting with a letter) dropped: the `kinds` the two others take |
|
|
1462
|
+
| `splitAnswerMarkers(content, streaming?, kinds?)` | The answer as ordered parts: `{ type: 'text', value }`, `{ type: 'file', fileId }`, `{ type: 'artifact', kind, id, slideRef? }` (`PRESENTATION`, `REPORT`, `VIDEO`, the id lower-cased, a deck's `#/<slide>`), and `{ type: 'marker', kind, args }` for a kind in `kinds` (a built-in one named there included, its arguments as written). A marker cut off at the end is dropped, and so, while `streaming`, is a chunk that stopped inside a marker's name (`[[PRES`). A marker of a kind no one names stays text |
|
|
1463
|
+
| `stripAnswerMarkers(content, kinds?)` | The answer with its markers removed (copy, a preview, a notification); prose with none is returned untouched |
|
|
1464
|
+
|
|
1465
|
+
## Workspace Helpers
|
|
1466
|
+
|
|
1467
|
+
A host listing conversations in a sidebar of its own groups them by workspace
|
|
1468
|
+
exactly as the panel's list does, from the dedicated **`@filigran/chatbot/workspaces`**
|
|
1469
|
+
entry point — pure functions, no React, no CSS:
|
|
1470
|
+
|
|
1471
|
+
```ts
|
|
1472
|
+
import { ELSEWHERE_GROUP, groupConversations, groupIsOpen, parseWorkspaces } from '@filigran/chatbot/workspaces';
|
|
1473
|
+
|
|
1474
|
+
const workspaces = parseWorkspaces(workspaceList, { userId: me.id });
|
|
1475
|
+
const { groups, unfiled } = groupConversations(
|
|
1476
|
+
conversations.map((c) => ({ ...c, conversationId: c.id, workspaceId: c.workspace_id })),
|
|
1477
|
+
workspaces,
|
|
1478
|
+
{ matches: search ? (c) => c.title.toLowerCase().includes(search) : undefined },
|
|
1479
|
+
);
|
|
1480
|
+
```
|
|
1481
|
+
|
|
1482
|
+
| Helper | What it does |
|
|
1483
|
+
| --- | --- |
|
|
1484
|
+
| `parseWorkspaces(data, { userId? })` | The backend's list (a bare array or `{ workspaces }`) as `ChatWorkspace`s; entries without an id or a name, and archived ones, are left out. A workspace is the caller's own (`isOwn`) by its `is_own`, else, where the backend sends none, by its `user_id` being `userId` |
|
|
1485
|
+
| `sortWorkspaces(workspaces)` | The caller's own default first, then by name |
|
|
1486
|
+
| `groupConversations(conversations, workspaces, { matches? })` | `{ groups, unfiled }`: a group (`{ workspace, key, conversations }`) per workspace the caller may file into, empty or not, and per one they may only read while it holds one of their conversations; those filed in a workspace not listed go to one group keyed `ELSEWHERE_GROUP` (`workspace: null`). With `matches` only the matching conversations stay, and groups left empty go. Each conversation needs `conversationId` and `workspaceId`; the host's own type passes through whole |
|
|
1487
|
+
| `fileableWorkspaces(workspaces)` | The workspaces a conversation can be moved into (`canManage`), in list order |
|
|
1488
|
+
| `groupIsOpen(key, collapsed, group, activeConversationId, searching)` / `groupCanToggle(group, activeConversationId, searching)` | Whether a group shows its conversations (collapsed, unless it holds the conversation on screen; always while searching), and whether its toggle may fold it |
|
|
1489
|
+
|
|
1238
1490
|
## Peer Dependencies
|
|
1239
1491
|
|
|
1240
1492
|
- `react` >= 18
|
|
@@ -1265,7 +1517,7 @@ To build:
|
|
|
1265
1517
|
yarn build
|
|
1266
1518
|
```
|
|
1267
1519
|
|
|
1268
|
-
To run the unit tests (Node's built-in test runner,
|
|
1520
|
+
To run the unit tests (Node's built-in test runner, with an esbuild loader for JSX and Node's own type stripping for TypeScript; Node 22.18 / 23.6 or later):
|
|
1269
1521
|
|
|
1270
1522
|
```bash
|
|
1271
1523
|
yarn test
|