@filigran/chatbot 3.7.4 → 3.9.1
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 +501 -41
- package/dist/index.d.ts +242 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/markdown.d.ts +73 -0
- package/dist/markdown.js +2 -0
- package/dist/markdown.js.map +1 -0
- package/dist/styles.css +1 -1
- package/package.json +8 -1
package/README.md
CHANGED
|
@@ -6,11 +6,18 @@ Filigran chat panel — a standalone React + Tailwind chatbot component with SSE
|
|
|
6
6
|
|
|
7
7
|
- 🔄 **SSE Message Streaming** — Real-time response streaming with status indicators
|
|
8
8
|
- ⚡ **Mid-Run Steering** — Send messages while the agent is generating; they are injected into the running agentic loop instead of waiting for the turn to finish
|
|
9
|
-
-
|
|
9
|
+
- ✋ **Tool Approval** — When the agent stops at a tool that needs a human's consent, the turn pauses mid-answer and the reviewer approves, declines with a reason, or approves always — opt-in per host, see [Tool approval](#post-apibaseurlapiendpointsapprove)
|
|
10
|
+
- 🗂️ **Conversation History** — Switch between (and delete) past conversations from a header menu, or from a permanent sidebar in fullscreen mode (collapsible, searchable past 7 entries, rename in place)
|
|
10
11
|
- 🤖 **Multi-Agent Support** — Switch between different AI agents
|
|
11
12
|
- 📎 **File Attachments** — Upload and paste files (PDF, TXT, images)
|
|
12
13
|
- 📥 **Agent-Generated Files** — Renders downloadable file cards from agent output and strips the `[[FILE:id]]` markers from the prose
|
|
13
|
-
- 📝 **Full Markdown** — Tables, code blocks with copy button, lists, blockquotes
|
|
14
|
+
- 📝 **Full Markdown** — Tables (mis-delimited ones repaired), code blocks with copy button, lists, blockquotes, soft line breaks, inline images with a lightbox
|
|
15
|
+
- 🖼️ **Image Previews** — `data:image/*` charts and image attachments render inline, click to expand
|
|
16
|
+
- 📋 **Copy & Rate** — Copy any answer; optional 👍/👎 feedback wired to the host
|
|
17
|
+
- 🧰 **Composer Toolbar** — Prompt library and quota indicator, both driven by whether the host serves the route; plus a slot for the host's own controls
|
|
18
|
+
- 🧠 **Context Gauge** — Ring + percentage showing how full the model's context window is, so a long chat's silent summarising is visible before it happens
|
|
19
|
+
- 🎙️ **Dictation** — Speech-to-text via the browser's own Web Speech API; no endpoint, no key, hidden where unsupported
|
|
20
|
+
- ✍️ **Draft Recovery** — Unsent composer text is kept per conversation and restored when the panel reopens
|
|
14
21
|
- 🎨 **Customizable Theme** — Accent color and logo customization
|
|
15
22
|
- 📱 **3 Display Modes** — Floating, sidebar (resizable), and fullscreen
|
|
16
23
|
- 💾 **Persistence** — Conversation and sidebar width saved to localStorage
|
|
@@ -74,6 +81,10 @@ import { ChatPanel } from '@filigran/chatbot';
|
|
|
74
81
|
| `onWidthChange` | `(width: number) => void` | — | Called when sidebar width changes during resize |
|
|
75
82
|
| `onResizeStart` | `() => void` | — | Called when resize drag starts |
|
|
76
83
|
| `onResizeEnd` | `() => void` | — | Called when resize drag ends |
|
|
84
|
+
| `onMessageFeedback` | `(id, feedback, message) => void` | — | Enables 👍/👎 on completed assistant answers and receives each rating (`null` clears it). Omit to hide the affordance — the panel stores nothing itself. |
|
|
85
|
+
| `disableImagePreviews` | `boolean` | `false` | Render image attachments as download cards instead of inline previews |
|
|
86
|
+
| `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). |
|
|
87
|
+
| `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. |
|
|
77
88
|
|
|
78
89
|
#### Resizable Sidebar Example
|
|
79
90
|
|
|
@@ -110,13 +121,14 @@ import { ChatToggleButton } from '@filigran/chatbot';
|
|
|
110
121
|
|
|
111
122
|
#### Props
|
|
112
123
|
|
|
113
|
-
| Prop | Type
|
|
114
|
-
| ------------- |
|
|
115
|
-
| `isOpen` | `boolean`
|
|
116
|
-
| `onToggle` | `() => void`
|
|
117
|
-
| `label` | `string`
|
|
118
|
-
| `
|
|
119
|
-
| `
|
|
124
|
+
| Prop | Type | Default | Description |
|
|
125
|
+
| ------------- | ------------------------- | ------------ | -------------------------------------------------------------------------- |
|
|
126
|
+
| `isOpen` | `boolean` | **required** | Whether the chat panel is open |
|
|
127
|
+
| `onToggle` | `() => void` | **required** | Called when button is clicked |
|
|
128
|
+
| `label` | `string` | built-in | Button text, already translated by the host; omit for `t('Ask Assistant')` |
|
|
129
|
+
| `t` | `(key: string) => string` | identity | Translation function, used for the default label |
|
|
130
|
+
| `accentColor` | `string` | `'#7b5cff'` | Button background color |
|
|
131
|
+
| `icon` | `React.ReactNode` | default icon | Custom icon |
|
|
120
132
|
|
|
121
133
|
## API Contract
|
|
122
134
|
|
|
@@ -219,6 +231,12 @@ the chat. Set `apiEndpoints.history` to `null` to hide the history menu
|
|
|
219
231
|
entirely, or point it at a dedicated path if your proxy can't route `GET` on
|
|
220
232
|
the sessions path.
|
|
221
233
|
|
|
234
|
+
### `PATCH {apiBaseUrl}/chat/sessions/{conversation_id}`
|
|
235
|
+
|
|
236
|
+
Renames a conversation. Body: `{ "title": "..." }`. Only reached from the
|
|
237
|
+
fullscreen sidebar; a backend without the route simply fails the request and
|
|
238
|
+
the row reverts to its previous title.
|
|
239
|
+
|
|
222
240
|
### `DELETE {apiBaseUrl}/chat/sessions/{conversation_id}`
|
|
223
241
|
|
|
224
242
|
Deletes a conversation from the history menu. Any 2xx response counts as
|
|
@@ -378,6 +396,134 @@ data: {"type": "stream", "content": "Follow-up answer to the steering message"}
|
|
|
378
396
|
data: {"type": "done", "content": "Follow-up answer to the steering message", "conversation_id": "uuid"}
|
|
379
397
|
```
|
|
380
398
|
|
|
399
|
+
### `POST {apiBaseUrl}{apiEndpoints.approve}`
|
|
400
|
+
|
|
401
|
+
Answers a turn that paused because the agent proposed a tool call requiring a
|
|
402
|
+
human's consent. **Opt-in: there is no default path.** The widget advertises
|
|
403
|
+
approval support to the backend only when `apiEndpoints.approve` is set, and
|
|
404
|
+
that flag is a promise — a backend that pauses a turn waits indefinitely for a
|
|
405
|
+
decision, with no timeout. A host that names a path it cannot route would
|
|
406
|
+
receive the pause, POST the decision into a 404, and hang the turn with the
|
|
407
|
+
user watching a spinner. Leave it unset and the backend degrades to an ordinary
|
|
408
|
+
assistant message explaining what it could not run, so an un-updated host keeps
|
|
409
|
+
working untouched.
|
|
410
|
+
|
|
411
|
+
When set, `supports_tool_approval: true` is sent on every `rest` message body.
|
|
412
|
+
|
|
413
|
+
**Server → client**, on the existing SSE stream, alongside `stream` / `status` /
|
|
414
|
+
`done`:
|
|
415
|
+
|
|
416
|
+
```
|
|
417
|
+
data: {"type": "approval_required", "conversation_id": "uuid-here", "proposals": [
|
|
418
|
+
{
|
|
419
|
+
"tool_call_id": "call_abc123",
|
|
420
|
+
"tool_name": "opencti_delete_entity",
|
|
421
|
+
"tool_description": "Permanently delete an entity from the platform.",
|
|
422
|
+
"arguments": {"entity_id": "e-123", "cascade": true},
|
|
423
|
+
"input_schema": {"type": "object", "properties": {
|
|
424
|
+
"entity_id": {"type": "string", "description": "Entity to delete"},
|
|
425
|
+
"cascade": {"type": "boolean", "description": "Also delete linked entities"}
|
|
426
|
+
}},
|
|
427
|
+
"source": "integration:opencti"
|
|
428
|
+
}
|
|
429
|
+
]}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
The turn is **not** over: no `done` arrives, the stream stays open and silent
|
|
433
|
+
(kept alive by SSE `: keepalive` comment lines, which the reader drops), and the
|
|
434
|
+
rest of the turn continues on it once a decision is sent. The progress bubble is
|
|
435
|
+
replaced by the prompt, which renders each argument next to its description from
|
|
436
|
+
`input_schema` — `cascade: true` is unjudgeable on its own, so a prompt showing
|
|
437
|
+
only names and values would be a rubber stamp.
|
|
438
|
+
|
|
439
|
+
**Client → server**, one decision per proposed call:
|
|
440
|
+
|
|
441
|
+
```json
|
|
442
|
+
{
|
|
443
|
+
"conversation_id": "uuid-here",
|
|
444
|
+
"decisions": [
|
|
445
|
+
{ "tool_call_id": "call_abc123", "decision": "approve" },
|
|
446
|
+
{ "tool_call_id": "call_ghi789", "decision": "reject", "rejection_reason": "Wrong target environment." },
|
|
447
|
+
{ "tool_call_id": "call_jkl012", "decision": "approve_always" }
|
|
448
|
+
]
|
|
449
|
+
}
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
| `decision` | Effect |
|
|
453
|
+
| ---------------- | ------------------------------------------------------------------- |
|
|
454
|
+
| `approve` | Runs with the arguments exactly as proposed |
|
|
455
|
+
| `reject` | Does not run; the agent receives `rejection_reason` and can adapt |
|
|
456
|
+
| `approve_always` | Runs, **and** saves a standing approval for this user |
|
|
457
|
+
|
|
458
|
+
Every proposed `tool_call_id` must appear exactly once — the backend refuses a
|
|
459
|
+
partial set, because resuming with an undecided call leaves a `tool_use` block
|
|
460
|
+
without its `tool_result`, which the model providers reject outright. The prompt
|
|
461
|
+
therefore submits itself once the last card is decided. A set containing
|
|
462
|
+
`approve_always` waits for an explicit Confirm instead: it is the only verdict
|
|
463
|
+
whose reach outlives the turn (it applies to the user's unattended scheduled
|
|
464
|
+
runs too), so the warning has to be read before it is committed.
|
|
465
|
+
|
|
466
|
+
A decision carries no arguments. Correcting a wrong proposal is what
|
|
467
|
+
`reject` with a reason is for — rewriting a call under the agent's name would
|
|
468
|
+
leave a transcript crediting it with arguments it never chose.
|
|
469
|
+
|
|
470
|
+
On a non-2xx the prompt stays on screen with the failure noted and the controls
|
|
471
|
+
re-armed: the turn is still paused either way, so clearing the prompt would
|
|
472
|
+
strand it with nothing able to answer. A `409` means nothing is waiting any more
|
|
473
|
+
(the turn finished, was cancelled, or was answered elsewhere); the stream ending
|
|
474
|
+
then clears the prompt on its own. Stopping the turn also dismisses it — the
|
|
475
|
+
backend waits indefinitely by design, so abandoning the stream is the reviewer's
|
|
476
|
+
only other way out.
|
|
477
|
+
|
|
478
|
+
**Proxied hosts:** the decision goes through the same fetch path as every other
|
|
479
|
+
endpoint — relative to `apiBaseUrl` and honouring `requestHeaders` — so CSRF
|
|
480
|
+
wrappers and per-request context headers keep working. A proxy in front of the
|
|
481
|
+
chat must forward the request body whole (a proxy rebuilding it from a fixed
|
|
482
|
+
field list silently drops `supports_tool_approval`), never time out the
|
|
483
|
+
streaming turn, and pass SSE keepalives through untouched.
|
|
484
|
+
|
|
485
|
+
#### Recovering a prompt after a page reload
|
|
486
|
+
|
|
487
|
+
`approval_required` is a single event on a stream, so a reload loses it —
|
|
488
|
+
including the `tool_call_id`s a decision has to name — while the turn goes on
|
|
489
|
+
waiting for an answer that can no longer be given. To the user that is a chat
|
|
490
|
+
which simply stopped replying.
|
|
491
|
+
|
|
492
|
+
Set `apiEndpoints.pendingApprovals` (XTM One: `/chat/conversations`) and the
|
|
493
|
+
panel asks once per conversation, on mount and on every conversation switch:
|
|
494
|
+
|
|
495
|
+
```
|
|
496
|
+
GET {apiBaseUrl}{apiEndpoints.pendingApprovals}/{conversation_id}/pending-approvals
|
|
497
|
+
→ {
|
|
498
|
+
"conversation_id": "uuid-here",
|
|
499
|
+
"proposals": [ /* as the event carried */ ],
|
|
500
|
+
"turn": "running" | "idle"
|
|
501
|
+
}
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
An empty `proposals` is the ordinary answer. A non-empty one re-renders the same
|
|
505
|
+
prompt, and the decision is POSTed exactly as before. Like `approve` this has no
|
|
506
|
+
default and is skipped when unset, leaving the live flow untouched.
|
|
507
|
+
|
|
508
|
+
The recovered turn resumes with the reasoning and tool results it had already
|
|
509
|
+
produced — but **not on a stream**: the one it would have reported on died with
|
|
510
|
+
the old page, so the backend persists the answer and suppresses the live `done`
|
|
511
|
+
frame. So after a decision on a recovered prompt the panel shows its ordinary
|
|
512
|
+
working indicator and polls this route every 5s, using `turn` as the stop
|
|
513
|
+
condition: while it reads `running` it keeps waiting, and the moment it reads
|
|
514
|
+
`idle` it re-reads the conversation once — the answer is there. A resumed turn
|
|
515
|
+
that pauses *again* on a second gated call is picked up by the same poll, which
|
|
516
|
+
is the only way that prompt could reach the user with no stream open.
|
|
517
|
+
|
|
518
|
+
One bound applies server-side: after 30 minutes with **no sign of a client** and
|
|
519
|
+
no decision, the turn stops waiting and the pause is discarded. Any request about
|
|
520
|
+
the conversation counts as a sign, so while a recovered prompt is displayed the
|
|
521
|
+
panel re-reads this route every 10 minutes purely to say someone is still there —
|
|
522
|
+
a tab left open makes no requests of its own, and the bound is meant to limit
|
|
523
|
+
abandonment, never the person deciding.
|
|
524
|
+
|
|
525
|
+
REST backend only: `legacy` and `ag-ui` never meet this gate.
|
|
526
|
+
|
|
381
527
|
## Customization
|
|
382
528
|
|
|
383
529
|
### Custom Logo
|
|
@@ -426,47 +572,230 @@ function App() {
|
|
|
426
572
|
}
|
|
427
573
|
```
|
|
428
574
|
|
|
429
|
-
|
|
575
|
+
`t` is only ever asked for a lookup — key in, translated string out — so any
|
|
576
|
+
i18n library works and the package never carries a dictionary of its own.
|
|
577
|
+
Untranslated hosts can omit it: the default is the identity function, so every
|
|
578
|
+
key is its own English text.
|
|
430
579
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
580
|
+
#### Values inside sentences
|
|
581
|
+
|
|
582
|
+
A key is always a **whole sentence**, with `{placeholder}` slots for the values.
|
|
583
|
+
The panel fills the slots in _after_ the lookup, so a locale is free to move the
|
|
584
|
+
value, and a translator sees the sentence rather than a fragment of one:
|
|
585
|
+
|
|
586
|
+
| Key | Renders as |
|
|
587
|
+
| --- | --- |
|
|
588
|
+
| `'Waiting for {count} background tasks…'` | Waiting for 3 background tasks… |
|
|
589
|
+
| `'Transferred from {agent}'` | Transferred from Threat Analyst |
|
|
590
|
+
| `'{percent}% full'` | 84% full |
|
|
591
|
+
| `'How can {agent} help you, {name}?'` | How can **Threat Analyst** help you, John? |
|
|
592
|
+
|
|
593
|
+
Two consequences worth knowing when writing the locale files:
|
|
594
|
+
|
|
595
|
+
- **Plurals are separate keys** — `'1 tool call'` and `'{count} tool calls'`,
|
|
596
|
+
`'Delegating one task…'` and `'Delegating {count} tasks…'`. A lookup cannot
|
|
597
|
+
select a plural form, so the panel picks the sentence and the locale
|
|
598
|
+
translates it whole; a language with more plural forms than English can route
|
|
599
|
+
the plural key through its own rules.
|
|
600
|
+
- **A missing slot is not fatal** — a translation that drops a `{placeholder}`
|
|
601
|
+
renders the rest of the sentence as it is, and the value is simply absent.
|
|
602
|
+
That includes the greeting, where the agent's name is markup: a locale that
|
|
603
|
+
rewords the sentence without `{agent}` gets the sentence, not a name dangling
|
|
604
|
+
off the end of it.
|
|
605
|
+
|
|
606
|
+
`promptSuggestions` (and any suggestions the backend serves) also go through
|
|
607
|
+
`t`, so a host may pass either keys or final text.
|
|
608
|
+
|
|
609
|
+
#### Translation keys used
|
|
610
|
+
|
|
611
|
+
Every key the package can ask for, grouped by where it appears:
|
|
612
|
+
|
|
613
|
+
**Toggle button**
|
|
614
|
+
|
|
615
|
+
- `'Ask Assistant'`
|
|
616
|
+
|
|
617
|
+
**Header, agent menu and history**
|
|
618
|
+
|
|
619
|
+
- `'Agent switching is not available here'`
|
|
620
|
+
- `'Browse agents'`
|
|
621
|
+
- `'Close'`
|
|
442
622
|
- `'Conversation history'`
|
|
443
|
-
- `'
|
|
444
|
-
- `'
|
|
445
|
-
- `'New conversation'`
|
|
623
|
+
- `'Could not reach the assistant service. Check the connection and try again.'`
|
|
624
|
+
- `'Create agent'`
|
|
446
625
|
- `'Delete conversation'`
|
|
447
|
-
- `'
|
|
448
|
-
- `'
|
|
449
|
-
- `'
|
|
626
|
+
- `'Floating'`
|
|
627
|
+
- `'Full screen'`
|
|
628
|
+
- `'New chat'`
|
|
629
|
+
- `'New conversation'`
|
|
630
|
+
- `'No agent matches'`
|
|
631
|
+
- `'No conversations yet'`
|
|
632
|
+
- `'Search agents...'`
|
|
633
|
+
- `'Sidebar'`
|
|
634
|
+
- `'Switch to'`
|
|
450
635
|
- `'Switch to another agent'`
|
|
451
|
-
- `'
|
|
452
|
-
- `'
|
|
636
|
+
- `'Switch view'`
|
|
637
|
+
- `'Transferred from {agent}'`
|
|
638
|
+
- `'Untitled conversation'`
|
|
639
|
+
|
|
640
|
+
**Conversation sidebar**
|
|
641
|
+
|
|
642
|
+
- `'Conversation title'`
|
|
643
|
+
- `'Hide conversations'`
|
|
644
|
+
- `'No conversation matches'`
|
|
645
|
+
- `'Rename conversation'`
|
|
646
|
+
- `'Search conversations...'`
|
|
647
|
+
- `'Show conversations'`
|
|
648
|
+
|
|
649
|
+
**Welcome screen**
|
|
650
|
+
|
|
651
|
+
- `'Assistant'`
|
|
652
|
+
- `'Help me create a new simulation scenario'`
|
|
653
|
+
- `'How can I help you, {name}?'`
|
|
654
|
+
- `'How can {agent} help you, {name}?'`
|
|
655
|
+
- `'How do I configure detection rules?'`
|
|
656
|
+
- `'Suggestions'`
|
|
657
|
+
- `'Summarize my recent findings'`
|
|
658
|
+
- `'What are the latest attack patterns?'`
|
|
659
|
+
|
|
660
|
+
**Composer**
|
|
661
|
+
|
|
662
|
+
- `'Ask a question...'`
|
|
663
|
+
- `'Attachments wait for the current response'`
|
|
664
|
+
- `'Dictate a message'`
|
|
665
|
+
- `'Enter to send now · Esc to stop'`
|
|
666
|
+
- `'Files uploading...'`
|
|
667
|
+
- `'Insert prompt template'`
|
|
668
|
+
- `'No prompt matches'`
|
|
669
|
+
- `'Search prompts...'`
|
|
670
|
+
- `'Send now'`
|
|
671
|
+
- `'Stop dictation'`
|
|
672
|
+
- `'Stop generating'`
|
|
673
|
+
- `'Uses AI. Verify results.'`
|
|
674
|
+
|
|
675
|
+
**Messages, markdown and files**
|
|
676
|
+
|
|
677
|
+
- `'Bad response'`
|
|
678
|
+
- `'Copied'`
|
|
679
|
+
- `'Copied!'`
|
|
680
|
+
- `'Copy code'`
|
|
681
|
+
- `'Copy response'`
|
|
682
|
+
- `'Download'`
|
|
683
|
+
- `'Expand image'`
|
|
684
|
+
- `'Good response'`
|
|
685
|
+
- `'Image could not be loaded'`
|
|
686
|
+
- `'Image preview'`
|
|
687
|
+
- `'Load earlier messages'`
|
|
688
|
+
- `'Loading image…'`
|
|
453
689
|
- `'Reasoning details'`
|
|
454
690
|
- `'Reasoning details — turn limit reached'`
|
|
691
|
+
|
|
692
|
+
**Agent status**
|
|
693
|
+
|
|
694
|
+
- `'Analyzing results…'`
|
|
695
|
+
- `'Collecting results from one task…'`
|
|
696
|
+
- `'Collecting results from {count} tasks…'`
|
|
697
|
+
- `'Composing answer…'`
|
|
698
|
+
- `'Consulting {agent}…'`
|
|
699
|
+
- `'Delegating one task…'`
|
|
700
|
+
- `'Delegating {count} tasks…'`
|
|
701
|
+
- `'Incorporating your message…'`
|
|
702
|
+
- `'Thinking...'`
|
|
703
|
+
- `'Transferring to {agent}…'`
|
|
704
|
+
- `'Using tools…'`
|
|
705
|
+
- `'Waiting for one background task…'`
|
|
706
|
+
- `'Waiting for your approval…'`
|
|
707
|
+
- `'Waiting for {count} background tasks…'`
|
|
708
|
+
- `'the agent'`
|
|
709
|
+
- `'{tool} (+{count} more)…'`
|
|
710
|
+
|
|
711
|
+
**Waiting mini-game**
|
|
712
|
+
|
|
713
|
+
- `'Almost there'`
|
|
714
|
+
- `'Analyzing the details'`
|
|
715
|
+
- `'Connecting the dots'`
|
|
716
|
+
- `'Consulting the sources'`
|
|
717
|
+
- `'Crunching the data'`
|
|
718
|
+
- `'Polishing the answer'`
|
|
719
|
+
- `'Putting it together'`
|
|
720
|
+
- `'Reticulating splines'`
|
|
721
|
+
- `'Thinking it through'`
|
|
722
|
+
- `'Turn off the waiting mini-game'`
|
|
723
|
+
- `'Turn on the waiting mini-game'`
|
|
724
|
+
- `'Wrapping things up'`
|
|
725
|
+
|
|
726
|
+
**Reasoning details**
|
|
727
|
+
|
|
728
|
+
- `'(no output)'`
|
|
729
|
+
- `'1 tool call'`
|
|
730
|
+
- `'1 transfer'`
|
|
731
|
+
- `'Input'`
|
|
455
732
|
- `'Model reasoning'`
|
|
456
|
-
- `'
|
|
457
|
-
- `'
|
|
733
|
+
- `'Output'`
|
|
734
|
+
- `"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."`
|
|
458
735
|
- `'Transfer chain'`
|
|
459
736
|
- `'Turn limit reached.'`
|
|
460
|
-
- `
|
|
461
|
-
- `'
|
|
462
|
-
- `'
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
- `'
|
|
467
|
-
- `'
|
|
468
|
-
- `'
|
|
469
|
-
- `'
|
|
737
|
+
- `'{count} iterations'`
|
|
738
|
+
- `'{count} tool calls'`
|
|
739
|
+
- `'{count} transfers'`
|
|
740
|
+
|
|
741
|
+
**Tool approval**
|
|
742
|
+
|
|
743
|
+
- `'Also applies to your scheduled runs, until you revoke it'`
|
|
744
|
+
- `'Always allowed'`
|
|
745
|
+
- `'Approved'`
|
|
746
|
+
- `'Back'`
|
|
747
|
+
- `'Confirm'`
|
|
748
|
+
- `'Decline this call'`
|
|
749
|
+
- `'Declined'`
|
|
750
|
+
- `'No'`
|
|
751
|
+
- `'Sending…'`
|
|
752
|
+
- `'The agent needs your approval to run a tool:'`
|
|
753
|
+
- `'The agent needs your approval to run these tools:'`
|
|
754
|
+
- `'Why not? The agent sees this and can adapt (optional)'`
|
|
755
|
+
- `'Yes'`
|
|
756
|
+
- `'Yes, always'`
|
|
757
|
+
- `'e.g. wrong environment — use staging instead'`
|
|
758
|
+
- `'unknown tool'`
|
|
759
|
+
- `'{decided}/{total} decided'`
|
|
760
|
+
- `'“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.'`
|
|
761
|
+
|
|
762
|
+
**Context gauge**
|
|
763
|
+
|
|
764
|
+
- `'Context full — older turns are being dropped'`
|
|
765
|
+
- `'Context nearly full — older turns are being summarized'`
|
|
766
|
+
- `'Context used'`
|
|
767
|
+
- `'Conversation'`
|
|
768
|
+
- `'MCP & dynamic tools'`
|
|
769
|
+
- `'Summarized conversation'`
|
|
770
|
+
- `'System prompt'`
|
|
771
|
+
- `'Tool definitions'`
|
|
772
|
+
- `'Tool results'`
|
|
773
|
+
- `'{counts} tokens'`
|
|
774
|
+
- `'{percent}% full'`
|
|
775
|
+
- `'{summary} — click for details'`
|
|
776
|
+
|
|
777
|
+
**Quota**
|
|
778
|
+
|
|
779
|
+
- `'Quota'`
|
|
780
|
+
- `'Quota reached'`
|
|
781
|
+
- `'Usage'`
|
|
782
|
+
- `'Usage · {period}'`
|
|
783
|
+
|
|
784
|
+
**Notices, errors and timestamps**
|
|
785
|
+
|
|
786
|
+
- `'Could not send your decision. Please try again.'`
|
|
787
|
+
- `'No response.'`
|
|
788
|
+
- `'Response ready'`
|
|
789
|
+
- `'Sorry, an error occurred. Please try again.'`
|
|
790
|
+
- `'This decision could not be sent. Reload the chat and try again.'`
|
|
791
|
+
- `'This turn is no longer waiting for a decision.'`
|
|
792
|
+
- `'Unable to connect. Please check the configuration.'`
|
|
793
|
+
- `'Your answer is ready'`
|
|
794
|
+
- `'just now'`
|
|
795
|
+
- `'{agent} has finished'`
|
|
796
|
+
- `'{count}d ago'`
|
|
797
|
+
- `'{count}h ago'`
|
|
798
|
+
- `'{count}m ago'`
|
|
470
799
|
|
|
471
800
|
## Styling
|
|
472
801
|
|
|
@@ -478,11 +807,142 @@ import '@filigran/chatbot/styles.css';
|
|
|
478
807
|
|
|
479
808
|
The component uses Tailwind CSS classes and CSS custom properties for theming. The accent color is applied via `--chat-accent` CSS variable.
|
|
480
809
|
|
|
810
|
+
### Composer toolbar
|
|
811
|
+
|
|
812
|
+
Two toolbar items are **data-driven rather than mode-driven**: they appear only
|
|
813
|
+
when the host serves the route, so the UI can never advertise something the
|
|
814
|
+
backend cannot answer, and there is no mode flag to keep in step.
|
|
815
|
+
|
|
816
|
+
| Endpoint | Default path | Response |
|
|
817
|
+
| --- | --- | --- |
|
|
818
|
+
| Prompt library | `GET {apiBaseUrl}/chat/prompts` | `[{ id, title, content, description? }]` (or `{ prompts: [...] }`) |
|
|
819
|
+
| Quota status | `GET {apiBaseUrl}/chat/quota` | `{ used: number, limit: number \| null, period: string }` |
|
|
820
|
+
| Agent suggestions | `GET {apiBaseUrl}/chat/suggestions?agent_slug=<slug>` | `["..."]` (or `{ suggestions: [...] }`, or objects with `prompt`/`label`/`text`) |
|
|
821
|
+
|
|
822
|
+
Set either to `null` in `apiEndpoints` to hide it. `limit: null` means no
|
|
823
|
+
ceiling — the indicator then shows consumption without a bar. The quota is
|
|
824
|
+
re-read whenever a turn finishes.
|
|
825
|
+
|
|
826
|
+
The welcome screen names the selected agent and shows its own suggestions —
|
|
827
|
+
which is also how switching agent is confirmed: the thread resets to that
|
|
828
|
+
screen, so without it nothing tells you who the next message will reach. When
|
|
829
|
+
the suggestions route is unavailable the host's `promptSuggestions` prop is used
|
|
830
|
+
instead, so the section is never empty.
|
|
831
|
+
|
|
832
|
+
Dictation needs no configuration at all: it uses the browser's own Web Speech
|
|
833
|
+
API, so the mic button appears wherever the API exists and is simply absent
|
|
834
|
+
elsewhere. Finalised phrases are appended to the composer (never replacing a
|
|
835
|
+
draft), interim words preview beside the button, and sending stops the mic so
|
|
836
|
+
the next words cannot land in a composer the user just emptied.
|
|
837
|
+
|
|
838
|
+
### Context usage
|
|
839
|
+
|
|
840
|
+
The composer also carries a context gauge — a small ring plus percentage
|
|
841
|
+
showing how full the model's context window is for the current conversation,
|
|
842
|
+
the affordance Cursor popularised. It answers one question: *is this
|
|
843
|
+
conversation about to get shorter than I think?* Long chats do not fail at the
|
|
844
|
+
window, they get silently summarised, and a user who cannot see that coming
|
|
845
|
+
reads the summary's gaps as the assistant forgetting.
|
|
846
|
+
|
|
847
|
+
Clicking it opens a breakdown: one stacked bar over the window plus a colour
|
|
848
|
+
legend, so "why is this chat 84 % full" has an answer the user can act on —
|
|
849
|
+
usually "the tool results", sometimes "the MCP tools you wired up".
|
|
850
|
+
|
|
851
|
+
Unlike the items above it needs no endpoint of its own. The backend reports the
|
|
852
|
+
occupancy on the frames it already sends:
|
|
853
|
+
|
|
854
|
+
| Frame | Extra keys | When |
|
|
855
|
+
| --- | --- | --- |
|
|
856
|
+
| `status: "thinking"` | `context_tokens`, `context_window`, `context_breakdown?` | Each agent-loop iteration, so the gauge climbs during a long turn |
|
|
857
|
+
| `done` | same | Closing value for the turn — a turn whose last iteration compacted ends lower than it peaked |
|
|
858
|
+
| Restored message (`POST /chat/sessions`) | same | On the newest assistant message, so a reload or conversation switch restores the gauge |
|
|
859
|
+
|
|
860
|
+
`context_tokens` and `context_window` are required together and the window must
|
|
861
|
+
be positive: a token count with no window to measure it against is not a ratio.
|
|
862
|
+
Anything else is ignored, so a backend that reports nothing simply has no gauge —
|
|
863
|
+
and a host on an older backend needs no configuration change.
|
|
864
|
+
|
|
865
|
+
`context_breakdown` is optional and validated independently, so a malformed one
|
|
866
|
+
costs the gauge its detail but never its number. Keys, all optional, in tokens:
|
|
867
|
+
|
|
868
|
+
| Key | Legend row |
|
|
869
|
+
| --- | --- |
|
|
870
|
+
| `system` | System prompt |
|
|
871
|
+
| `tools` | Tool definitions |
|
|
872
|
+
| `dynamic_tools` | MCP & dynamic tools |
|
|
873
|
+
| `summary` | Summarized conversation |
|
|
874
|
+
| `tool_results` | Tool results |
|
|
875
|
+
| `conversation` | Conversation |
|
|
876
|
+
|
|
877
|
+
Only positive values are shown, so a chat with no digest yet simply has no
|
|
878
|
+
"Summarized conversation" row. **The values must sum to `context_tokens`** — the
|
|
879
|
+
popover shows the lines and the headline together, and a breakdown whose parts
|
|
880
|
+
do not add up reads as broken numbers rather than as rounding. A producer doing
|
|
881
|
+
per-bucket integer division has to distribute the remainder rather than drop it.
|
|
882
|
+
|
|
883
|
+
Colours track the backend's own thresholds, not a design choice: neutral below
|
|
884
|
+
80 % (where XTM One's agent loop starts distilling older turns into a summary),
|
|
885
|
+
amber past it, red past 95 % (where it emergency-prunes). `used` is a
|
|
886
|
+
char-derived estimate of what the next call will carry — deliberately a forecast
|
|
887
|
+
rather than a receipt for the turn that just ended, which is why the figures are
|
|
888
|
+
prefixed with `~`.
|
|
889
|
+
|
|
890
|
+
Only the `rest` backend carries the figures today.
|
|
891
|
+
|
|
892
|
+
Anything host-specific goes through `composerToolbar`:
|
|
893
|
+
|
|
894
|
+
```tsx
|
|
895
|
+
<ChatPanel
|
|
896
|
+
composerToolbar={<MySessionToolPicker />}
|
|
897
|
+
{...rest}
|
|
898
|
+
/>
|
|
899
|
+
```
|
|
900
|
+
|
|
901
|
+
## Markdown Helpers
|
|
902
|
+
|
|
903
|
+
A host that renders assistant prose with its **own** markdown component (its
|
|
904
|
+
design tokens, its icon set) should still normalise the text the same way the
|
|
905
|
+
panel does, rather than maintaining a divergent copy. These pure
|
|
906
|
+
`string → string` helpers ship from the dedicated **`@filigran/chatbot/markdown`**
|
|
907
|
+
entry point — ~2 kB, no React, no CSS. Import them from there and never from the
|
|
908
|
+
package root, which is the full panel bundle:
|
|
909
|
+
|
|
910
|
+
```tsx
|
|
911
|
+
import {
|
|
912
|
+
hardenNestedCodeFences,
|
|
913
|
+
markdownUrlTransform,
|
|
914
|
+
normalizeImageMarkdown,
|
|
915
|
+
normalizeMarkdownTables,
|
|
916
|
+
wrapBareJson,
|
|
917
|
+
} from '@filigran/chatbot/markdown';
|
|
918
|
+
|
|
919
|
+
const processed = hardenNestedCodeFences(
|
|
920
|
+
normalizeMarkdownTables(wrapBareJson(normalizeImageMarkdown(content))),
|
|
921
|
+
);
|
|
922
|
+
|
|
923
|
+
<ReactMarkdown urlTransform={markdownUrlTransform}>{processed}</ReactMarkdown>;
|
|
924
|
+
```
|
|
925
|
+
|
|
926
|
+
Order matters: alt-text is flattened before anything reads line structure, and
|
|
927
|
+
the JSON wrap must see the raw payload before fences are hardened.
|
|
928
|
+
|
|
929
|
+
| Helper | Fixes |
|
|
930
|
+
| ------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
931
|
+
| `normalizeImageMarkdown` | Multi-line `` alt text, which breaks the image into literal paragraphs plus a stray link |
|
|
932
|
+
| `wrapBareJson` | A whole message that is raw JSON — fenced as ```json so it stays readable and copyable |
|
|
933
|
+
| `normalizeMarkdownTables` | A delimiter row whose column count doesn't match the header, including tables nested in blockquotes / list items |
|
|
934
|
+
| `hardenNestedCodeFences` | A ```markdown block containing its own ``` fences, which shatters the snippet into alternating code and prose |
|
|
935
|
+
| `markdownUrlTransform` | react-markdown's default sanitiser stripping `data:image/*` URIs (code-interpreter charts). Still blocks `javascript:` and non-image `data:` |
|
|
936
|
+
|
|
937
|
+
None of them touch content they don't apply to — an already-valid document is
|
|
938
|
+
returned byte-identical.
|
|
939
|
+
|
|
481
940
|
## Peer Dependencies
|
|
482
941
|
|
|
483
942
|
- `react` >= 18
|
|
484
943
|
- `react-dom` >= 18
|
|
485
944
|
- `react-markdown` >= 10
|
|
945
|
+
- `remark-breaks` >= 4
|
|
486
946
|
- `remark-gfm` >= 4
|
|
487
947
|
|
|
488
948
|
---
|