@filigran/chatbot 3.7.4 → 3.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
- - 🗂️ **Conversation History** — Switch between (and delete) past conversations from a history menu in the header
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
 
@@ -219,6 +230,12 @@ the chat. Set `apiEndpoints.history` to `null` to hide the history menu
219
230
  entirely, or point it at a dedicated path if your proxy can't route `GET` on
220
231
  the sessions path.
221
232
 
233
+ ### `PATCH {apiBaseUrl}/chat/sessions/{conversation_id}`
234
+
235
+ Renames a conversation. Body: `{ "title": "..." }`. Only reached from the
236
+ fullscreen sidebar; a backend without the route simply fails the request and
237
+ the row reverts to its previous title.
238
+
222
239
  ### `DELETE {apiBaseUrl}/chat/sessions/{conversation_id}`
223
240
 
224
241
  Deletes a conversation from the history menu. Any 2xx response counts as
@@ -378,6 +395,134 @@ data: {"type": "stream", "content": "Follow-up answer to the steering message"}
378
395
  data: {"type": "done", "content": "Follow-up answer to the steering message", "conversation_id": "uuid"}
379
396
  ```
380
397
 
398
+ ### `POST {apiBaseUrl}{apiEndpoints.approve}`
399
+
400
+ Answers a turn that paused because the agent proposed a tool call requiring a
401
+ human's consent. **Opt-in: there is no default path.** The widget advertises
402
+ approval support to the backend only when `apiEndpoints.approve` is set, and
403
+ that flag is a promise — a backend that pauses a turn waits indefinitely for a
404
+ decision, with no timeout. A host that names a path it cannot route would
405
+ receive the pause, POST the decision into a 404, and hang the turn with the
406
+ user watching a spinner. Leave it unset and the backend degrades to an ordinary
407
+ assistant message explaining what it could not run, so an un-updated host keeps
408
+ working untouched.
409
+
410
+ When set, `supports_tool_approval: true` is sent on every `rest` message body.
411
+
412
+ **Server → client**, on the existing SSE stream, alongside `stream` / `status` /
413
+ `done`:
414
+
415
+ ```
416
+ data: {"type": "approval_required", "conversation_id": "uuid-here", "proposals": [
417
+ {
418
+ "tool_call_id": "call_abc123",
419
+ "tool_name": "opencti_delete_entity",
420
+ "tool_description": "Permanently delete an entity from the platform.",
421
+ "arguments": {"entity_id": "e-123", "cascade": true},
422
+ "input_schema": {"type": "object", "properties": {
423
+ "entity_id": {"type": "string", "description": "Entity to delete"},
424
+ "cascade": {"type": "boolean", "description": "Also delete linked entities"}
425
+ }},
426
+ "source": "integration:opencti"
427
+ }
428
+ ]}
429
+ ```
430
+
431
+ The turn is **not** over: no `done` arrives, the stream stays open and silent
432
+ (kept alive by SSE `: keepalive` comment lines, which the reader drops), and the
433
+ rest of the turn continues on it once a decision is sent. The progress bubble is
434
+ replaced by the prompt, which renders each argument next to its description from
435
+ `input_schema` — `cascade: true` is unjudgeable on its own, so a prompt showing
436
+ only names and values would be a rubber stamp.
437
+
438
+ **Client → server**, one decision per proposed call:
439
+
440
+ ```json
441
+ {
442
+ "conversation_id": "uuid-here",
443
+ "decisions": [
444
+ { "tool_call_id": "call_abc123", "decision": "approve" },
445
+ { "tool_call_id": "call_ghi789", "decision": "reject", "rejection_reason": "Wrong target environment." },
446
+ { "tool_call_id": "call_jkl012", "decision": "approve_always" }
447
+ ]
448
+ }
449
+ ```
450
+
451
+ | `decision` | Effect |
452
+ | ---------------- | ------------------------------------------------------------------- |
453
+ | `approve` | Runs with the arguments exactly as proposed |
454
+ | `reject` | Does not run; the agent receives `rejection_reason` and can adapt |
455
+ | `approve_always` | Runs, **and** saves a standing approval for this user |
456
+
457
+ Every proposed `tool_call_id` must appear exactly once — the backend refuses a
458
+ partial set, because resuming with an undecided call leaves a `tool_use` block
459
+ without its `tool_result`, which the model providers reject outright. The prompt
460
+ therefore submits itself once the last card is decided. A set containing
461
+ `approve_always` waits for an explicit Confirm instead: it is the only verdict
462
+ whose reach outlives the turn (it applies to the user's unattended scheduled
463
+ runs too), so the warning has to be read before it is committed.
464
+
465
+ A decision carries no arguments. Correcting a wrong proposal is what
466
+ `reject` with a reason is for — rewriting a call under the agent's name would
467
+ leave a transcript crediting it with arguments it never chose.
468
+
469
+ On a non-2xx the prompt stays on screen with the failure noted and the controls
470
+ re-armed: the turn is still paused either way, so clearing the prompt would
471
+ strand it with nothing able to answer. A `409` means nothing is waiting any more
472
+ (the turn finished, was cancelled, or was answered elsewhere); the stream ending
473
+ then clears the prompt on its own. Stopping the turn also dismisses it — the
474
+ backend waits indefinitely by design, so abandoning the stream is the reviewer's
475
+ only other way out.
476
+
477
+ **Proxied hosts:** the decision goes through the same fetch path as every other
478
+ endpoint — relative to `apiBaseUrl` and honouring `requestHeaders` — so CSRF
479
+ wrappers and per-request context headers keep working. A proxy in front of the
480
+ chat must forward the request body whole (a proxy rebuilding it from a fixed
481
+ field list silently drops `supports_tool_approval`), never time out the
482
+ streaming turn, and pass SSE keepalives through untouched.
483
+
484
+ #### Recovering a prompt after a page reload
485
+
486
+ `approval_required` is a single event on a stream, so a reload loses it —
487
+ including the `tool_call_id`s a decision has to name — while the turn goes on
488
+ waiting for an answer that can no longer be given. To the user that is a chat
489
+ which simply stopped replying.
490
+
491
+ Set `apiEndpoints.pendingApprovals` (XTM One: `/chat/conversations`) and the
492
+ panel asks once per conversation, on mount and on every conversation switch:
493
+
494
+ ```
495
+ GET {apiBaseUrl}{apiEndpoints.pendingApprovals}/{conversation_id}/pending-approvals
496
+ → {
497
+ "conversation_id": "uuid-here",
498
+ "proposals": [ /* as the event carried */ ],
499
+ "turn": "running" | "idle"
500
+ }
501
+ ```
502
+
503
+ An empty `proposals` is the ordinary answer. A non-empty one re-renders the same
504
+ prompt, and the decision is POSTed exactly as before. Like `approve` this has no
505
+ default and is skipped when unset, leaving the live flow untouched.
506
+
507
+ The recovered turn resumes with the reasoning and tool results it had already
508
+ produced — but **not on a stream**: the one it would have reported on died with
509
+ the old page, so the backend persists the answer and suppresses the live `done`
510
+ frame. So after a decision on a recovered prompt the panel shows its ordinary
511
+ working indicator and polls this route every 5s, using `turn` as the stop
512
+ condition: while it reads `running` it keeps waiting, and the moment it reads
513
+ `idle` it re-reads the conversation once — the answer is there. A resumed turn
514
+ that pauses *again* on a second gated call is picked up by the same poll, which
515
+ is the only way that prompt could reach the user with no stream open.
516
+
517
+ One bound applies server-side: after 30 minutes with **no sign of a client** and
518
+ no decision, the turn stops waiting and the pause is discarded. Any request about
519
+ the conversation counts as a sign, so while a recovered prompt is displayed the
520
+ panel re-reads this route every 10 minutes purely to say someone is still there —
521
+ a tab left open makes no requests of its own, and the bound is meant to limit
522
+ abandonment, never the person deciding.
523
+
524
+ REST backend only: `legacy` and `ag-ui` never meet this gate.
525
+
381
526
  ## Customization
382
527
 
383
528
  ### Custom Logo
@@ -464,6 +609,18 @@ function App() {
464
609
  - `'Uses AI. Verify results.'`
465
610
  - `'How can I help you, '`
466
611
  - `'Suggestions'`
612
+ - `'Waiting for your approval…'`
613
+ - `'The agent needs your approval to run a tool:'` / `'The agent needs your approval to run these tools:'`
614
+ - `'Yes'` / `'No'` / `'Yes, always'` / `'Back'` / `'Confirm'` / `'Sending…'` / `'decided'`
615
+ - `'Approved'` / `'Declined'` / `'Always allowed'`
616
+ - `'Decline this call'`
617
+ - `'Why not? The agent sees this and can adapt (optional)'`
618
+ - `'e.g. wrong environment — use staging instead'`
619
+ - `'Also applies to your scheduled runs, until you revoke it'`
620
+ - `'“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.'`
621
+ - `'This turn is no longer waiting for a decision.'`
622
+ - `'Could not send your decision. Please try again.'`
623
+ - `'This decision could not be sent. Reload the chat and try again.'`
467
624
  - `'Floating'`
468
625
  - `'Sidebar'`
469
626
  - `'Full screen'`
@@ -478,11 +635,142 @@ import '@filigran/chatbot/styles.css';
478
635
 
479
636
  The component uses Tailwind CSS classes and CSS custom properties for theming. The accent color is applied via `--chat-accent` CSS variable.
480
637
 
638
+ ### Composer toolbar
639
+
640
+ Two toolbar items are **data-driven rather than mode-driven**: they appear only
641
+ when the host serves the route, so the UI can never advertise something the
642
+ backend cannot answer, and there is no mode flag to keep in step.
643
+
644
+ | Endpoint | Default path | Response |
645
+ | --- | --- | --- |
646
+ | Prompt library | `GET {apiBaseUrl}/chat/prompts` | `[{ id, title, content, description? }]` (or `{ prompts: [...] }`) |
647
+ | Quota status | `GET {apiBaseUrl}/chat/quota` | `{ used: number, limit: number \| null, period: string }` |
648
+ | Agent suggestions | `GET {apiBaseUrl}/chat/suggestions?agent_slug=<slug>` | `["..."]` (or `{ suggestions: [...] }`, or objects with `prompt`/`label`/`text`) |
649
+
650
+ Set either to `null` in `apiEndpoints` to hide it. `limit: null` means no
651
+ ceiling — the indicator then shows consumption without a bar. The quota is
652
+ re-read whenever a turn finishes.
653
+
654
+ The welcome screen names the selected agent and shows its own suggestions —
655
+ which is also how switching agent is confirmed: the thread resets to that
656
+ screen, so without it nothing tells you who the next message will reach. When
657
+ the suggestions route is unavailable the host's `promptSuggestions` prop is used
658
+ instead, so the section is never empty.
659
+
660
+ Dictation needs no configuration at all: it uses the browser's own Web Speech
661
+ API, so the mic button appears wherever the API exists and is simply absent
662
+ elsewhere. Finalised phrases are appended to the composer (never replacing a
663
+ draft), interim words preview beside the button, and sending stops the mic so
664
+ the next words cannot land in a composer the user just emptied.
665
+
666
+ ### Context usage
667
+
668
+ The composer also carries a context gauge — a small ring plus percentage
669
+ showing how full the model's context window is for the current conversation,
670
+ the affordance Cursor popularised. It answers one question: *is this
671
+ conversation about to get shorter than I think?* Long chats do not fail at the
672
+ window, they get silently summarised, and a user who cannot see that coming
673
+ reads the summary's gaps as the assistant forgetting.
674
+
675
+ Clicking it opens a breakdown: one stacked bar over the window plus a colour
676
+ legend, so "why is this chat 84 % full" has an answer the user can act on —
677
+ usually "the tool results", sometimes "the MCP tools you wired up".
678
+
679
+ Unlike the items above it needs no endpoint of its own. The backend reports the
680
+ occupancy on the frames it already sends:
681
+
682
+ | Frame | Extra keys | When |
683
+ | --- | --- | --- |
684
+ | `status: "thinking"` | `context_tokens`, `context_window`, `context_breakdown?` | Each agent-loop iteration, so the gauge climbs during a long turn |
685
+ | `done` | same | Closing value for the turn — a turn whose last iteration compacted ends lower than it peaked |
686
+ | Restored message (`POST /chat/sessions`) | same | On the newest assistant message, so a reload or conversation switch restores the gauge |
687
+
688
+ `context_tokens` and `context_window` are required together and the window must
689
+ be positive: a token count with no window to measure it against is not a ratio.
690
+ Anything else is ignored, so a backend that reports nothing simply has no gauge —
691
+ and a host on an older backend needs no configuration change.
692
+
693
+ `context_breakdown` is optional and validated independently, so a malformed one
694
+ costs the gauge its detail but never its number. Keys, all optional, in tokens:
695
+
696
+ | Key | Legend row |
697
+ | --- | --- |
698
+ | `system` | System prompt |
699
+ | `tools` | Tool definitions |
700
+ | `dynamic_tools` | MCP & dynamic tools |
701
+ | `summary` | Summarized conversation |
702
+ | `tool_results` | Tool results |
703
+ | `conversation` | Conversation |
704
+
705
+ Only positive values are shown, so a chat with no digest yet simply has no
706
+ "Summarized conversation" row. **The values must sum to `context_tokens`** — the
707
+ popover shows the lines and the headline together, and a breakdown whose parts
708
+ do not add up reads as broken numbers rather than as rounding. A producer doing
709
+ per-bucket integer division has to distribute the remainder rather than drop it.
710
+
711
+ Colours track the backend's own thresholds, not a design choice: neutral below
712
+ 80 % (where XTM One's agent loop starts distilling older turns into a summary),
713
+ amber past it, red past 95 % (where it emergency-prunes). `used` is a
714
+ char-derived estimate of what the next call will carry — deliberately a forecast
715
+ rather than a receipt for the turn that just ended, which is why the figures are
716
+ prefixed with `~`.
717
+
718
+ Only the `rest` backend carries the figures today.
719
+
720
+ Anything host-specific goes through `composerToolbar`:
721
+
722
+ ```tsx
723
+ <ChatPanel
724
+ composerToolbar={<MySessionToolPicker />}
725
+ {...rest}
726
+ />
727
+ ```
728
+
729
+ ## Markdown Helpers
730
+
731
+ A host that renders assistant prose with its **own** markdown component (its
732
+ design tokens, its icon set) should still normalise the text the same way the
733
+ panel does, rather than maintaining a divergent copy. These pure
734
+ `string → string` helpers ship from the dedicated **`@filigran/chatbot/markdown`**
735
+ entry point — ~2 kB, no React, no CSS. Import them from there and never from the
736
+ package root, which is the full panel bundle:
737
+
738
+ ```tsx
739
+ import {
740
+ hardenNestedCodeFences,
741
+ markdownUrlTransform,
742
+ normalizeImageMarkdown,
743
+ normalizeMarkdownTables,
744
+ wrapBareJson,
745
+ } from '@filigran/chatbot/markdown';
746
+
747
+ const processed = hardenNestedCodeFences(
748
+ normalizeMarkdownTables(wrapBareJson(normalizeImageMarkdown(content))),
749
+ );
750
+
751
+ <ReactMarkdown urlTransform={markdownUrlTransform}>{processed}</ReactMarkdown>;
752
+ ```
753
+
754
+ Order matters: alt-text is flattened before anything reads line structure, and
755
+ the JSON wrap must see the raw payload before fences are hardened.
756
+
757
+ | Helper | Fixes |
758
+ | ------------------------- | -------------------------------------------------------------------------------------------------------------- |
759
+ | `normalizeImageMarkdown` | Multi-line `![alt](url)` alt text, which breaks the image into literal paragraphs plus a stray link |
760
+ | `wrapBareJson` | A whole message that is raw JSON — fenced as ```json so it stays readable and copyable |
761
+ | `normalizeMarkdownTables` | A delimiter row whose column count doesn't match the header, including tables nested in blockquotes / list items |
762
+ | `hardenNestedCodeFences` | A ```markdown block containing its own ``` fences, which shatters the snippet into alternating code and prose |
763
+ | `markdownUrlTransform` | react-markdown's default sanitiser stripping `data:image/*` URIs (code-interpreter charts). Still blocks `javascript:` and non-image `data:` |
764
+
765
+ None of them touch content they don't apply to — an already-valid document is
766
+ returned byte-identical.
767
+
481
768
  ## Peer Dependencies
482
769
 
483
770
  - `react` >= 18
484
771
  - `react-dom` >= 18
485
772
  - `react-markdown` >= 10
773
+ - `remark-breaks` >= 4
486
774
  - `remark-gfm` >= 4
487
775
 
488
776
  ---
package/dist/index.d.ts CHANGED
@@ -2,6 +2,8 @@ import { FunctionComponent } from 'react';
2
2
 
3
3
  type ChatMode = 'sidebar' | 'floating' | 'fullscreen';
4
4
  type BackendType = 'legacy' | 'rest' | 'ag-ui';
5
+ /** A user's rating of one assistant answer. */
6
+ type MessageFeedback = 'up' | 'down';
5
7
  /**
6
8
  * Custom API endpoint configuration.
7
9
  * When using single endpoint mode (like OpenCTI's /chatbot), set singleEndpoint to true
@@ -23,6 +25,46 @@ interface ApiEndpoints {
23
25
  * disable mid-run steering entirely.
24
26
  */
25
27
  steer?: string | null;
28
+ /**
29
+ * Path for submitting tool-approval decisions when the agent pauses on a
30
+ * gated tool call. The widget POSTs
31
+ * `{ conversation_id, decisions: [{ tool_call_id, decision, rejection_reason }] }`
32
+ * and the paused turn resumes on the same stream with the tool result.
33
+ *
34
+ * **No default, unlike every sibling entry, and that is deliberate.** Setting
35
+ * this is what makes the widget advertise `supports_tool_approval` to the
36
+ * backend, and advertising it is a promise to answer: a backend that pauses a
37
+ * turn waits indefinitely for a decision, with no timeout and no safe default
38
+ * action to take on the reviewer's behalf. If this path defaulted to XTM
39
+ * One's own route, a host proxying the chat (OpenCTI, OpenAEV, OpenGRC) could
40
+ * upgrade the widget without adding the matching proxy route, claim support,
41
+ * receive the pause, POST the decision into a 404 — and hang the turn with
42
+ * the user watching a spinner.
43
+ *
44
+ * While unset the widget never claims support and the backend degrades to a
45
+ * plain assistant message explaining what it could not run, which is why an
46
+ * un-updated host keeps working untouched.
47
+ *
48
+ * REST backend only: `legacy` and `ag-ui` never meet this gate.
49
+ */
50
+ approve?: string | null;
51
+ /**
52
+ * Base path for recovering what a paused turn is still waiting on, read as
53
+ * `GET {apiBaseUrl}{pendingApprovals}/{conversation_id}/pending-approvals`
54
+ * (the same base-plus-suffix idiom as {@link ApiEndpoints.download}). XTM
55
+ * One serves it at `/chat/conversations`.
56
+ *
57
+ * `approval_required` is a single event on a stream, so a reload loses it —
58
+ * including the `tool_call_id`s a decision has to name. The turn is left
59
+ * waiting for an answer nobody can give, which reads as a chat that simply
60
+ * stopped replying. The panel therefore asks once per conversation on mount;
61
+ * an empty list is the ordinary answer.
62
+ *
63
+ * No default, for the same reason as {@link ApiEndpoints.approve}: a proxied
64
+ * host has to expose the route before the panel starts calling it. Unset, the
65
+ * live flow still works and only reload recovery is absent.
66
+ */
67
+ pendingApprovals?: string | null;
26
68
  /** Path for fetching agents. Default: '/chat/agents'. Set to null to disable. */
27
69
  agents?: string | null;
28
70
  /** Path for fetching session history. Default: '/chat/sessions'. Set to null to disable. */
@@ -51,6 +93,140 @@ interface ApiEndpoints {
51
93
  * unless this path is set explicitly to a proxy route.
52
94
  */
53
95
  download?: string | null;
96
+ /**
97
+ * Path for the prompt library shown in the composer toolbar.
98
+ * Default: '/chat/prompts'. Set to null to hide the affordance.
99
+ *
100
+ * Visibility is data-driven on purpose: a host that does not serve this
101
+ * route simply has no prompt button, so there is no separate "mode" to keep
102
+ * in step with what the backend actually implements.
103
+ */
104
+ prompts?: string | null;
105
+ /**
106
+ * Path for the quota indicator shown in the composer toolbar.
107
+ * Default: '/chat/quota'. Set to null to hide the affordance.
108
+ */
109
+ quota?: string | null;
110
+ /**
111
+ * Path for per-agent suggested actions shown on the welcome screen.
112
+ * Default: '/chat/suggestions'. Set to null to always use the host's
113
+ * `promptSuggestions` prop instead.
114
+ *
115
+ * Called as `GET {suggestions}?agent_slug=<slug>`. A backend that ignores
116
+ * the parameter still answers with a generic set, so the same route carries
117
+ * both today's generic suggestions and per-agent (later per-user) ones.
118
+ */
119
+ suggestions?: string | null;
120
+ }
121
+ /** A reusable prompt the user can insert into the composer. */
122
+ interface ChatPromptTemplate {
123
+ id: string;
124
+ title: string;
125
+ /** The text inserted into the composer when picked. */
126
+ content: string;
127
+ description?: string;
128
+ }
129
+ /**
130
+ * Agentic quota headroom for the current user, as the composer indicator needs
131
+ * it — deliberately just the three numbers it renders. Where the limit comes
132
+ * from (user override, group, platform, licence) is the host platform's own
133
+ * business and has no place on an embedded surface.
134
+ */
135
+ interface ChatQuotaStatus {
136
+ used: number;
137
+ /** null means unlimited — the indicator then shows usage without a bar. */
138
+ limit: number | null;
139
+ /** Human-readable period label, e.g. "monthly". */
140
+ period: string;
141
+ }
142
+ /**
143
+ * One tool call the agent wants to make and is waiting on a human to approve.
144
+ *
145
+ * `inputSchema` travels alongside `arguments` so each value can be rendered
146
+ * with the tool's own description of what it means. That pairing is the whole
147
+ * point: `cascade: true` is unjudgeable on its own, while "cascade — also
148
+ * delete linked entities" is a decision someone can actually make. A prompt
149
+ * that shows names and values alone is a rubber stamp wearing the costume of a
150
+ * safety control.
151
+ */
152
+ interface ToolApprovalProposal {
153
+ /**
154
+ * Identity of the proposed call, and the key every decision is sent back on.
155
+ * Never the tool name: one turn can propose the same tool twice with
156
+ * different arguments.
157
+ */
158
+ toolCallId: string;
159
+ toolName: string;
160
+ toolDescription?: string;
161
+ arguments: Record<string, unknown>;
162
+ /** JSON Schema the arguments came from, used to label each one. */
163
+ inputSchema?: Record<string, unknown>;
164
+ /** Where the tool comes from, e.g. `integration:opencti`. */
165
+ source?: string;
166
+ }
167
+ /**
168
+ * A reviewer's verdict on one proposed call.
169
+ *
170
+ * There is deliberately no "edit the arguments" verdict. Rewriting a call under
171
+ * the agent's name would leave a transcript crediting it with arguments it
172
+ * never chose, and turn a yes/no judgement into authoring. A wrong proposal is
173
+ * corrected by rejecting it with a reason the agent can act on.
174
+ */
175
+ type ToolApprovalVerdict = 'approve' | 'approve_always' | 'reject';
176
+ /** One decision, ready to be sent back to the paused turn. */
177
+ interface ToolApprovalDecision {
178
+ toolCallId: string;
179
+ verdict: ToolApprovalVerdict;
180
+ /**
181
+ * Why the call was declined. Optional, and the agent's only signal to correct
182
+ * itself — with argument editing gone, a rejection *is* the correction
183
+ * channel.
184
+ */
185
+ rejectionReason?: string;
186
+ }
187
+ /**
188
+ * How full the model's context window is for the current conversation, as the
189
+ * composer gauge needs it — a ratio, so both halves are required.
190
+ *
191
+ * `used` is the backend's own estimate of what the next turn will carry, not a
192
+ * billed token count: it is the figure the agent loop budgets its compaction
193
+ * against, which is what makes the gauge predictive of the summarising the user
194
+ * is about to see rather than a receipt for the turn that just ended.
195
+ */
196
+ interface ChatContextUsage {
197
+ /** Estimated tokens currently occupying the window. */
198
+ used: number;
199
+ /** The model's context window, in tokens. Always > 0. */
200
+ limit: number;
201
+ /** Where those tokens went, when the backend reports it. */
202
+ breakdown?: ChatContextBreakdown;
203
+ }
204
+ /**
205
+ * What the context is being spent on, in tokens.
206
+ *
207
+ * Buckets are grouped by what the user can *do* about each: they can start a new
208
+ * chat (`conversation`), they cannot shrink the agent's persona (`system`), and
209
+ * the two the backend manages on their behalf (`summary`, `toolResults`) are
210
+ * what explain a long chat that seems to have forgotten things.
211
+ *
212
+ * Every key is optional and only non-zero ones are reported, so a backend that
213
+ * measures a different set — or none — still renders. Tool definitions are
214
+ * included even though some backends may not count them in their compaction
215
+ * gate; the gauge is meant to reflect what the prompt actually carries.
216
+ */
217
+ interface ChatContextBreakdown {
218
+ /** The agent's system prompt and any in-run instructions. */
219
+ system?: number;
220
+ /** Schemas of the platform's built-in tools. */
221
+ tools?: number;
222
+ /** Schemas of the agent's own integrations, MCP servers and custom tools. */
223
+ dynamicTools?: number;
224
+ /** Digests of earlier turns produced by the backend's compaction. */
225
+ summary?: number;
226
+ /** The user's and assistant's own messages. */
227
+ conversation?: number;
228
+ /** Output of tool calls still held verbatim in the context. */
229
+ toolResults?: number;
54
230
  }
55
231
  interface ChatPanelProps {
56
232
  mode: ChatMode;
@@ -149,6 +325,42 @@ interface ChatPanelProps {
149
325
  * Receives the already-translated `title` and `body`.
150
326
  */
151
327
  onTaskComplete?: (title: string, body: string) => void;
328
+ /**
329
+ * Enables the 👍/👎 affordance on completed assistant messages and receives
330
+ * each rating. `feedback` is `null` when the user clears a previous rating.
331
+ * Omit to hide the affordance entirely — the chatbot stores nothing itself,
332
+ * so a host without a feedback endpoint should not show the buttons.
333
+ */
334
+ onMessageFeedback?: (messageId: string, feedback: MessageFeedback | null, message: ChatMessage) => void;
335
+ /**
336
+ * Disable the inline preview of image attachments (they render as ordinary
337
+ * download cards instead). Previews fetch the image through the host download
338
+ * proxy, so hosts that meter or restrict that endpoint can opt out.
339
+ * Default: false.
340
+ */
341
+ disableImagePreviews?: boolean;
342
+ /**
343
+ * Show how full the model's context window is for the current conversation
344
+ * — a small ring plus percentage in the composer toolbar, so the user can
345
+ * see a long chat approaching the point where the agent starts summarising
346
+ * older turns and decide to split the work instead. Default: true.
347
+ *
348
+ * A host-level master switch, not a mode flag: the gauge is data-driven and
349
+ * simply absent until the backend reports occupancy (only the XTM One REST
350
+ * backend does today), so leaving this on costs nothing on a backend that
351
+ * says nothing.
352
+ */
353
+ contextUsageEnabled?: boolean;
354
+ /**
355
+ * Rendered into the composer toolbar, after the built-in controls.
356
+ *
357
+ * The escape hatch for anything the package has no business knowing about —
358
+ * XTM One's session-tool picker (integrations, MCP servers, knowledge bases)
359
+ * being the motivating case. A host that passes nothing gets no extra
360
+ * controls, so this doubles as the "product" toolbar: there is no mode flag
361
+ * to keep in step, only the presence or absence of what a host provides.
362
+ */
363
+ composerToolbar?: React.ReactNode;
152
364
  }
153
365
  interface ChatToggleButtonProps {
154
366
  isOpen: boolean;
@@ -162,6 +374,16 @@ interface ChatMessage {
162
374
  role: 'user' | 'assistant';
163
375
  content: string;
164
376
  timestamp: Date;
377
+ /**
378
+ * The agent that produced *this* message, when the backend says so.
379
+ *
380
+ * Takes precedence over the panel-wide name, which is the currently selected
381
+ * agent and therefore wrong for history: reopening a thread used to relabel
382
+ * every past answer with whoever happened to be picked in the menu. Optional,
383
+ * because no backend records per-message attribution yet — the panel falls
384
+ * back to the conversation's agent, then to the selected one.
385
+ */
386
+ agentName?: string;
165
387
  files?: ChatFile[];
166
388
  /** Agent-generated downloadable files attached to an assistant message. */
167
389
  attachments?: ChatAttachment[];
@@ -247,6 +469,13 @@ interface ChatConversationSummary {
247
469
  /** ISO timestamp of the last activity, used for the relative-time label. */
248
470
  updatedAt?: string;
249
471
  messageCount?: number;
472
+ /**
473
+ * The agent this conversation belongs to, when the backend reports it — so
474
+ * the history list can say which agent a thread is with before you open it.
475
+ * Undefined on backends that do not send it, null-ish for conversations that
476
+ * genuinely have no agent.
477
+ */
478
+ agentName?: string;
250
479
  }
251
480
  interface XtmAgent {
252
481
  id: string;
@@ -266,4 +495,4 @@ interface TransferredAgent {
266
495
  }
267
496
 
268
497
  export { ChatPanel, ChatToggleButton };
269
- export type { ApiEndpoints, BackendType, ChatAttachment, ChatConversationSummary, ChatFile, ChatMessage, ChatMode, ChatPanelProps, ChatToggleButtonProps, TransferredAgent, XtmAgent };
498
+ export type { ApiEndpoints, BackendType, ChatAttachment, ChatContextBreakdown, ChatContextUsage, ChatConversationSummary, ChatFile, ChatMessage, ChatMode, ChatPanelProps, ChatPromptTemplate, ChatQuotaStatus, ChatToggleButtonProps, MessageFeedback, ToolApprovalDecision, ToolApprovalProposal, ToolApprovalVerdict, TransferredAgent, XtmAgent };