@kodaris/krubble-app-components 1.0.111 → 1.0.113

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/dist/chatbot.js CHANGED
@@ -15,6 +15,25 @@ import { marked } from 'marked';
15
15
  // (kr-dialog, kr-tab-group, kr-tab, kr-text-field) used by the browse dialog.
16
16
  import { KRFilePreview, KRSnackbar } from '@kodaris/krubble-components';
17
17
  import { KRHttp } from '@kodaris/krubble-http';
18
+ /**
19
+ * The action-confirmation decisions, in the left-to-right order they render and
20
+ * the order the arrow keys walk. Yes is first so it's the default highlight.
21
+ * Shared by the render and the keyboard handler so the two never drift.
22
+ */
23
+ const CONFIRMATION_DECISIONS = [
24
+ {
25
+ decision: 'approve',
26
+ label: 'Yes',
27
+ },
28
+ {
29
+ decision: 'approveAlways',
30
+ label: 'Always allow',
31
+ },
32
+ {
33
+ decision: 'reject',
34
+ label: 'No',
35
+ },
36
+ ];
18
37
  /** Image extensions the chatbot renders as thumbnails (used when MIME is absent). */
19
38
  const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
20
39
  /**
@@ -49,9 +68,20 @@ const ACCEPTED_FILE_EXTENSIONS = [
49
68
  * - `TOOL_RESULT` — `{ type: "TOOL_RESULT", message: "tool_name", data: {...} }`
50
69
  * - `ERROR` — `{ type: "ERROR", message: "Something went wrong" }`
51
70
  * - `HEARTBEAT` — `{ type: "HEARTBEAT" }` (ignored)
71
+ * - `ACTION_CONFIRMATION_REQUIRED` — a guarded tool paused for approval;
72
+ * `{ type, data: { actions: [...] }, finished: true }` (Rail 3).
52
73
  *
53
74
  * The final event should include `finished: true` to signal stream completion.
54
75
  *
76
+ * ## Turn locking (when the composer is free to send)
77
+ * `finished: true` is the single reliable "turn over, safe to send" signal — the
78
+ * component unlocks the composer on it, and also on stream close/error as a
79
+ * fallback so a dropped stream never wedges the UI. The one exception is
80
+ * `ACTION_CONFIRMATION_REQUIRED`: it too carries `finished: true`, but the
81
+ * conversation is now awaiting an approve/deny decision, so the composer stays
82
+ * blocked and only a confirmation decision (not a new message) is a valid next
83
+ * call.
84
+ *
55
85
  * ## Usage
56
86
  * ```html
57
87
  * <kr-chatbot
@@ -221,6 +251,32 @@ let KRChatbot = class KRChatbot extends LitElement {
221
251
  this._contextLimitReached = false;
222
252
  /** Server-provided limit text (`CONVERSATION_LIMIT_REACHED`), if any. */
223
253
  this._contextLimitMessage = '';
254
+ /**
255
+ * The guarded tool awaiting the user's decision, from the turn's
256
+ * `ACTION_CONFIRMATION_REQUIRED` event (`data.actions[]`, of which we handle the
257
+ * first — the framework confirms one guarded tool at a time). Non-empty means
258
+ * the conversation is paused on a confirmation: the normal composer is blocked
259
+ * (a new chat message would be rejected by the server with
260
+ * `awaitingConfirmation`) and the approve/deny prompt is shown instead. Cleared
261
+ * once a decision is sent.
262
+ *
263
+ * This is the one case where `finished:true` unlocks the turn lock but does
264
+ * NOT free the composer — the only valid next call is a confirmation decision.
265
+ */
266
+ this._pendingActions = [];
267
+ /**
268
+ * Keyboard-highlighted confirmation option, as an index into the decision list
269
+ * (0 = Yes, 1 = Always allow, 2 = No). Yes is highlighted by default so Enter
270
+ * approves. Arrow Left/Right move it; Enter activates it. -1 when no batch is
271
+ * pending.
272
+ */
273
+ this._confirmationHighlighted = -1;
274
+ /**
275
+ * True while a confirmation decision (approve/deny) is being sent and its
276
+ * follow-up turn streams. Disables the approve/deny buttons so the batch can't
277
+ * be resolved twice.
278
+ */
279
+ this._confirmationInFlight = false;
224
280
  /**
225
281
  * INTERNAL-ONLY latest telemetry snapshot (last-value-wins). Populated from
226
282
  * `TOKEN_USAGE` events during streaming and hydrated from the conversation
@@ -374,6 +430,36 @@ let KRChatbot = class KRChatbot extends LitElement {
374
430
  document.removeEventListener('mouseup', this._handleMouseUp);
375
431
  this._saveState();
376
432
  };
433
+ /**
434
+ * Keyboard control for the confirmation prompt. Bound at the document so it
435
+ * works even though the composer (where focus usually sits) is blocked while a
436
+ * batch is pending: Left/Right move the highlight across Yes / Always allow /
437
+ * No, Enter activates the highlighted one. No-op when nothing is pending or a
438
+ * decision is already in flight.
439
+ */
440
+ this._handleDocumentKeydown = (e) => {
441
+ if (!this._pendingActions.length || this._confirmationInFlight) {
442
+ return;
443
+ }
444
+ if (e.key === 'ArrowRight') {
445
+ e.preventDefault();
446
+ this._confirmationHighlighted = (this._confirmationHighlighted + 1) % CONFIRMATION_DECISIONS.length;
447
+ return;
448
+ }
449
+ if (e.key === 'ArrowLeft') {
450
+ e.preventDefault();
451
+ this._confirmationHighlighted =
452
+ (this._confirmationHighlighted - 1 + CONFIRMATION_DECISIONS.length) % CONFIRMATION_DECISIONS.length;
453
+ return;
454
+ }
455
+ if (e.key === 'Enter') {
456
+ e.preventDefault();
457
+ const option = CONFIRMATION_DECISIONS[this._confirmationHighlighted];
458
+ if (option) {
459
+ this._handleConfirmationDecision(option.decision);
460
+ }
461
+ }
462
+ };
377
463
  this._handleDebugResizeMouseMove = (e) => {
378
464
  if (!this._isDebugResizing) {
379
465
  return;
@@ -459,6 +545,9 @@ let KRChatbot = class KRChatbot extends LitElement {
459
545
  }
460
546
  this._restoreState();
461
547
  document.addEventListener('click', this._handleDocumentClick);
548
+ // Drive the confirmation prompt from the keyboard even when focus isn't in the
549
+ // (blocked) composer: Left/Right move the highlight, Enter activates it.
550
+ document.addEventListener('keydown', this._handleDocumentKeydown);
462
551
  }
463
552
  disconnectedCallback() {
464
553
  super.disconnectedCallback();
@@ -469,6 +558,7 @@ let KRChatbot = class KRChatbot extends LitElement {
469
558
  document.removeEventListener('mousemove', this._handleMouseMove);
470
559
  document.removeEventListener('mouseup', this._handleMouseUp);
471
560
  document.removeEventListener('click', this._handleDocumentClick);
561
+ document.removeEventListener('keydown', this._handleDocumentKeydown);
472
562
  // Release the mic if the chatbot is torn down mid-dictation — otherwise the
473
563
  // recognizer keeps the browser's recording indicator lit with nothing to
474
564
  // transcribe into. abort() (not stop()) since the discarded text has nowhere to go.
@@ -1343,6 +1433,13 @@ let KRChatbot = class KRChatbot extends LitElement {
1343
1433
  if (this._isStreaming) {
1344
1434
  return;
1345
1435
  }
1436
+ // A guarded-tool batch is awaiting a decision. The turn lock released with
1437
+ // its finished:true, but the server rejects a normal chat message with
1438
+ // awaitingConfirmation until it's resolved — so the composer stays blocked
1439
+ // and the approve/deny prompt owns the next call.
1440
+ if (this._pendingActions.length) {
1441
+ return;
1442
+ }
1346
1443
  const messageText = this._inputValue.trim();
1347
1444
  const pending = this._pendingConversationFiles;
1348
1445
  // Allow sending an image with no text, but require at least one of them.
@@ -1449,6 +1546,22 @@ let KRChatbot = class KRChatbot extends LitElement {
1449
1546
  // the consumer hasn't provided their own `send`.
1450
1547
  if (!this.send) {
1451
1548
  this.send = (params) => {
1549
+ // Rail-3 confirmation decision: resolve a paused guarded-tool batch by
1550
+ // hitting the SAME streaming endpoint with `confirmation` set. There's no
1551
+ // userText/files on this path (the server ignores message text here), and
1552
+ // the conversation is always already pinned (a batch can't pause before
1553
+ // the conversation exists), so no upload and no createNew.
1554
+ if (params.confirmation) {
1555
+ return KRHttp.fetch({
1556
+ url: '/api/system/integration/aws/bedrock/stream/invokeModelForConversationWithState',
1557
+ method: 'POST',
1558
+ headers: { Accept: 'text/event-stream' },
1559
+ body: {
1560
+ confirmation: params.confirmation,
1561
+ conversationID: this.conversationId,
1562
+ },
1563
+ });
1564
+ }
1452
1565
  // Images are upload-first: each file is stored as a conversation file
1453
1566
  // (type LIBRARY — the format lives in mimeType), then the resulting
1454
1567
  // conversationFileIDs are attached to the message via `fileIds` on the
@@ -1588,6 +1701,59 @@ let KRChatbot = class KRChatbot extends LitElement {
1588
1701
  }
1589
1702
  });
1590
1703
  }
1704
+ /**
1705
+ * Resolve the paused guarded-tool batch (Rail 3). Hits the SAME streaming
1706
+ * endpoint as a normal turn but with a `confirmation` value instead of a
1707
+ * message; the server runs (approve/approveAlways) or denies (reject) the batch
1708
+ * and continues the turn, streaming the outcome into a fresh assistant bubble.
1709
+ *
1710
+ * The decision uses the same `send` callback so a custom `send` can own its own
1711
+ * targeting — it just receives `confirmation` set and no message.
1712
+ */
1713
+ _handleConfirmationDecision(decision) {
1714
+ if (this._confirmationInFlight) {
1715
+ return;
1716
+ }
1717
+ this._confirmationInFlight = true;
1718
+ this._error = '';
1719
+ // The batch is being resolved — drop the prompt so the composer unblocks once
1720
+ // this follow-up turn ends, and stream the outcome into a new assistant bubble.
1721
+ this._pendingActions = [];
1722
+ this._confirmationHighlighted = -1;
1723
+ this.messages = [...this.messages, {
1724
+ type: 'assistant',
1725
+ content: '',
1726
+ }];
1727
+ this._isStreaming = true;
1728
+ this._userHasScrolledUp = false;
1729
+ this.send({
1730
+ message: '',
1731
+ messages: this.messages.slice(0, -1),
1732
+ conversationFiles: [],
1733
+ pageContext: '',
1734
+ selectionContext: '',
1735
+ confirmation: decision,
1736
+ }).then((response) => {
1737
+ this._confirmationInFlight = false;
1738
+ if (!response.ok) {
1739
+ this._handleStreamError('Request failed: ' + response.status);
1740
+ return;
1741
+ }
1742
+ if (!response.body) {
1743
+ this._handleStreamError('No response body');
1744
+ return;
1745
+ }
1746
+ this._reader = response.body.getReader();
1747
+ this._decoder = new TextDecoder();
1748
+ this._buffer = '';
1749
+ this._readStreamChunk();
1750
+ }).catch((err) => {
1751
+ this._confirmationInFlight = false;
1752
+ if (err.name !== 'AbortError') {
1753
+ this._handleStreamError(err.message);
1754
+ }
1755
+ });
1756
+ }
1591
1757
  /**
1592
1758
  * Capture ambient "where is the user / what are they looking at" context at
1593
1759
  * send time, so the AI is grounded without the host passing anything in.
@@ -1680,8 +1846,11 @@ let KRChatbot = class KRChatbot extends LitElement {
1680
1846
  }
1681
1847
  this._reader.read().then((result) => {
1682
1848
  if (result.done) {
1683
- this._isStreaming = false;
1684
- this._reader = null;
1849
+ // Fallback unlock: the stream closed without a `finished:true` chunk
1850
+ // (network drop, server crash, the 5-min emitter timeout). Treat it as
1851
+ // the turn ending so a dropped stream never wedges the composer — the
1852
+ // server self-heals its turn lock and bounces any premature send.
1853
+ this._finishStream();
1685
1854
  return;
1686
1855
  }
1687
1856
  this._buffer = this._buffer + this._decoder.decode(result.value, { stream: true });
@@ -1766,6 +1935,20 @@ let KRChatbot = class KRChatbot extends LitElement {
1766
1935
  this._contextLimitMessage = String(data.message || '');
1767
1936
  this._contextWarning = '';
1768
1937
  }
1938
+ else if (data.type === 'ACTION_CONFIRMATION_REQUIRED') {
1939
+ // A guarded tool paused for approval (Rail 3). Carries finished:true
1940
+ // (handled below), so the turn lock releases — but the conversation
1941
+ // is now awaiting a decision, and a normal chat message would be
1942
+ // rejected with awaitingConfirmation. Capture the batch so the
1943
+ // composer stays blocked and the approve/deny prompt renders instead;
1944
+ // the next call must be a confirmation, not a userText.
1945
+ const actions = data.data?.actions;
1946
+ if (Array.isArray(actions) && actions.length) {
1947
+ this._pendingActions = actions;
1948
+ // Default the keyboard highlight to Yes so Enter approves.
1949
+ this._confirmationHighlighted = 0;
1950
+ }
1951
+ }
1769
1952
  else if (data.type === 'TOKEN_USAGE') {
1770
1953
  // INTERNAL-ONLY debug telemetry. Update the panel snapshot and the
1771
1954
  // per-iteration log; never render as a chat bubble.
@@ -1796,11 +1979,17 @@ let KRChatbot = class KRChatbot extends LitElement {
1796
1979
  }
1797
1980
  }
1798
1981
  if (data.finished) {
1799
- this._isStreaming = false;
1800
- this._reader = null;
1982
+ // Primary unlock signal: the turn is over and the server released
1983
+ // the turn lock. The SSE stream closes right after this chunk.
1984
+ this._finishStream();
1801
1985
  }
1802
1986
  }
1803
1987
  }
1988
+ // Stop reading once a terminal chunk ended the turn — the reader is nulled
1989
+ // by _finishStream and the stream is closing anyway.
1990
+ if (!this._reader) {
1991
+ return;
1992
+ }
1804
1993
  this._readStreamChunk();
1805
1994
  }).catch((err) => {
1806
1995
  if (err.name !== 'AbortError') {
@@ -1808,6 +1997,32 @@ let KRChatbot = class KRChatbot extends LitElement {
1808
1997
  }
1809
1998
  });
1810
1999
  }
2000
+ /**
2001
+ * End the current turn: release the turn lock and the reader. Called on the
2002
+ * primary unlock signal (`finished:true`) and the fallback (stream close).
2003
+ *
2004
+ * A turn that paused for confirmation (`_pendingActions` set) leaves the
2005
+ * composer blocked even though the lock released — the approve/deny prompt
2006
+ * owns the next call. Its trailing empty assistant bubble is dropped so the
2007
+ * prompt doesn't sit under an empty message with a typing indicator.
2008
+ */
2009
+ _finishStream() {
2010
+ this._isStreaming = false;
2011
+ this._reader = null;
2012
+ if (this._pendingActions.length) {
2013
+ const lastIndex = this.messages.length - 1;
2014
+ // Drop the trailing assistant bubble only when it's truly empty — no
2015
+ // streamed text AND no reasoning. A reasoning-only bubble (the model thought
2016
+ // out loud, then went straight to the guarded tool) must be kept, else its
2017
+ // "Thinking" trace is lost when the confirmation card renders.
2018
+ if (lastIndex >= 0
2019
+ && this.messages[lastIndex].type === 'assistant'
2020
+ && !this.messages[lastIndex].content
2021
+ && !this.messages[lastIndex].reasoning) {
2022
+ this.messages = this.messages.slice(0, -1);
2023
+ }
2024
+ }
2025
+ }
1811
2026
  _handleStreamError(message) {
1812
2027
  this._isStreaming = false;
1813
2028
  this._error = message;
@@ -1824,6 +2039,52 @@ let KRChatbot = class KRChatbot extends LitElement {
1824
2039
  }
1825
2040
  }
1826
2041
  // --- Render ---
2042
+ /**
2043
+ * The approve/deny card shown when a guarded tool is paused (Rail 3). Floats
2044
+ * OVER the messages, anchored just above the composer — not part of the message
2045
+ * scroll — so it stays in view while the transcript scrolls behind it.
2046
+ *
2047
+ * Written for a non-technical user: one plain-language sentence saying what's
2048
+ * about to happen (the model's schema-required `confirmationMessage`), then a
2049
+ * row of Yes / Always allow / No. Deliberately NO tool code, NO raw
2050
+ * arguments/JSON, and NO separate `description` line — those are noise (or
2051
+ * alarming) to a normal user; the confirmation sentence is the whole story.
2052
+ */
2053
+ _renderConfirmation() {
2054
+ // The framework confirms one guarded tool at a time, so we render the first
2055
+ // (and only) pending action.
2056
+ const action = this._pendingActions[0];
2057
+ // Title names the tool being run ("Run {tool}?").
2058
+ let title = 'Run this action?';
2059
+ if (action) {
2060
+ title = `Run ${action.displayName || action.toolName}?`;
2061
+ }
2062
+ return html `
2063
+ <div
2064
+ class="confirmation"
2065
+ role="alertdialog"
2066
+ aria-label="Confirm before continuing"
2067
+ >
2068
+ <div class="confirmation__title">${title}</div>
2069
+ <p class="confirmation__message">${action?.confirmationMessage || 'The assistant wants to take an action. Is that OK?'}</p>
2070
+ <div class="confirmation__actions">
2071
+ ${CONFIRMATION_DECISIONS.map((option, index) => html `
2072
+ <kr-button
2073
+ class=${classMap({
2074
+ 'confirmation__btn': true,
2075
+ 'confirmation__btn--highlighted': index === this._confirmationHighlighted,
2076
+ })}
2077
+ variant=${option.decision === 'approve' ? 'flat' : 'outline'}
2078
+ color=${option.decision === 'reject' ? 'secondary' : 'primary'}
2079
+ size="small"
2080
+ ?disabled=${this._confirmationInFlight}
2081
+ @click=${() => this._handleConfirmationDecision(option.decision)}
2082
+ >${option.label}</kr-button>
2083
+ `)}
2084
+ </div>
2085
+ </div>
2086
+ `;
2087
+ }
1827
2088
  /** The `/` skill menu shown above the composer while typing a slash command. */
1828
2089
  _renderSkillMenu() {
1829
2090
  const skills = this._invocableSkills;
@@ -2447,6 +2708,8 @@ let KRChatbot = class KRChatbot extends LitElement {
2447
2708
  </div>
2448
2709
  </div>
2449
2710
 
2711
+ ${this._pendingActions.length ? this._renderConfirmation() : nothing}
2712
+
2450
2713
  ${this._error ? html `
2451
2714
  <div class="error">
2452
2715
  <span>${this._error}</span>
@@ -2642,10 +2905,13 @@ let KRChatbot = class KRChatbot extends LitElement {
2642
2905
  <button
2643
2906
  class=${classMap({
2644
2907
  'send': true,
2645
- 'send--disabled': !this._inputValue.trim() && !this._pendingConversationFiles.length,
2908
+ // Blocked while a guarded-tool batch awaits a decision — a
2909
+ // normal message would be rejected until it's resolved.
2910
+ 'send--disabled': (!this._inputValue.trim() && !this._pendingConversationFiles.length)
2911
+ || this._pendingActions.length > 0,
2646
2912
  })}
2647
2913
  @click=${this._handleSubmit}
2648
- title="Send message"
2914
+ title=${this._pendingActions.length ? 'Approve or deny the pending action first' : 'Send message'}
2649
2915
  >
2650
2916
  <svg
2651
2917
  width="16"
@@ -3190,6 +3456,68 @@ KRChatbot.styles = css `
3190
3456
  flex-shrink: 0;
3191
3457
  }
3192
3458
 
3459
+ /* Action-confirmation card (Rail 3). Floats OVER the messages, anchored just
3460
+ above the composer — not part of the message scroll — so it stays put and
3461
+ in view while the transcript scrolls behind it. Its width matches the
3462
+ message text column: .messages-inner is 768px max with 32px padding, so the
3463
+ text column is 768 - 64. Plain-language ask + Yes/No for a non-technical
3464
+ user; no tool codes, no raw arguments. */
3465
+ .confirmation {
3466
+ position: absolute;
3467
+ left: 32px;
3468
+ right: 32px;
3469
+ /* Sits above the footer/composer with a clear gap between the two. */
3470
+ bottom: 104px;
3471
+ z-index: 6;
3472
+ max-width: 720px;
3473
+ margin: 0 auto;
3474
+ padding: 24px;
3475
+ border: 1px solid #d8d8d8;
3476
+ border-radius: 8px;
3477
+ background: var(--kr-chatbot-bg);
3478
+ color: var(--kr-chatbot-text);
3479
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
3480
+ /* Never taller than the panel leaves room for — scroll the card itself if a
3481
+ batched multi-action ask is long, rather than covering the whole panel. */
3482
+ max-height: calc(100% - 176px);
3483
+ overflow-y: auto;
3484
+ }
3485
+
3486
+ /* "Run {tool}?" heading — names the tool being run. */
3487
+ .confirmation__title {
3488
+ font-size: 16px;
3489
+ font-weight: 600;
3490
+ margin-bottom: 8px;
3491
+ }
3492
+
3493
+ .confirmation__message {
3494
+ margin: 0;
3495
+ font-size: 14px;
3496
+ line-height: 1.5;
3497
+ }
3498
+
3499
+ .confirmation__message + .confirmation__message {
3500
+ margin-top: 6px;
3501
+ }
3502
+
3503
+ /* Buttons in a row; wrap on a narrow panel rather than overflowing the card. */
3504
+ .confirmation__actions {
3505
+ display: flex;
3506
+ flex-wrap: wrap;
3507
+ align-items: center;
3508
+ gap: 8px;
3509
+ margin-top: 16px;
3510
+ }
3511
+
3512
+ /* Keyboard-highlighted decision — a focus ring around the button host, drawn
3513
+ outside its border so it reads over both the flat (Yes) and outline
3514
+ variants without recoloring the button itself. */
3515
+ .confirmation__btn--highlighted {
3516
+ border-radius: 8px;
3517
+ outline: 2px solid var(--kr-chatbot-primary);
3518
+ outline-offset: 2px;
3519
+ }
3520
+
3193
3521
  /*
3194
3522
  * INTERNAL-ONLY debug telemetry drawer. A layout drawer docked to the right
3195
3523
  * of the inline chat — the chat column shrinks to make room; it does not
@@ -4130,6 +4458,15 @@ __decorate([
4130
4458
  __decorate([
4131
4459
  state()
4132
4460
  ], KRChatbot.prototype, "_contextLimitMessage", void 0);
4461
+ __decorate([
4462
+ state()
4463
+ ], KRChatbot.prototype, "_pendingActions", void 0);
4464
+ __decorate([
4465
+ state()
4466
+ ], KRChatbot.prototype, "_confirmationHighlighted", void 0);
4467
+ __decorate([
4468
+ state()
4469
+ ], KRChatbot.prototype, "_confirmationInFlight", void 0);
4133
4470
  __decorate([
4134
4471
  state()
4135
4472
  ], KRChatbot.prototype, "_telemetry", void 0);