@kodaris/krubble-app-components 1.0.69 → 1.0.71

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
@@ -8,7 +8,12 @@ import { LitElement, html, css, nothing } from 'lit';
8
8
  import { customElement, property, state, query } from 'lit/decorators.js';
9
9
  import { classMap } from 'lit/directives/class-map.js';
10
10
  import { unsafeHTML } from 'lit/directives/unsafe-html.js';
11
+ import { repeat } from 'lit/directives/repeat.js';
11
12
  import { marked } from 'marked';
13
+ import { KRFilePreview } from '@kodaris/krubble-components';
14
+ import { KRHttp } from '@kodaris/krubble-http';
15
+ /** Image MIME types the chatbot accepts (matches what Bedrock can ingest). */
16
+ const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
12
17
  /**
13
18
  * AI chatbot component with SSE streaming.
14
19
  *
@@ -29,7 +34,7 @@ import { marked } from 'marked';
29
34
  * ## Usage
30
35
  * ```html
31
36
  * <kr-chatbot
32
- * title="AI Assistant"
37
+ * label="AI Assistant"
33
38
  * .send=${(params) => {
34
39
  * return fetch('/api/ai/chat/stream', {
35
40
  * method: 'POST',
@@ -64,20 +69,33 @@ let KRChatbot = class KRChatbot extends LitElement {
64
69
  this._dragStartTop = 0;
65
70
  this._resizeStartW = 0;
66
71
  this._resizeStartH = 0;
72
+ /** Counter for depth-aware dragenter/dragleave tracking. */
73
+ this._dragDepth = 0;
74
+ /** Monotonic id source for pending attachments. */
75
+ this._pendingSeq = 0;
67
76
  // --- @property (public API) ---
68
77
  /**
69
78
  * Callback function for sending messages. Receives the user's message and
70
79
  * current conversation history. Must return a Response with a streaming SSE body.
80
+ *
81
+ * Optional — when not set, the chatbot uses its built-in default that streams
82
+ * from the Kodaris Bedrock conversation endpoints. Set this to override.
71
83
  */
72
84
  this.send = null;
73
85
  /**
74
86
  * Callback function called when the user clears the conversation.
75
87
  * Must return a Promise — messages are only cleared if it resolves.
88
+ *
89
+ * Optional — when not set, the chatbot uses its built-in default that clears
90
+ * the Kodaris Bedrock conversation state. Set this to override.
76
91
  */
77
92
  this.clear = null;
78
93
  /**
79
94
  * Callback function called on init to load existing conversation messages.
80
95
  * Must return a Promise that resolves with an array of KRChatbotMessage.
96
+ *
97
+ * Optional — when not set, the chatbot uses its built-in default that loads
98
+ * existing messages from the Kodaris Bedrock conversation. Set this to override.
81
99
  */
82
100
  this.load = null;
83
101
  /**
@@ -86,13 +104,9 @@ let KRChatbot = class KRChatbot extends LitElement {
86
104
  */
87
105
  this.messages = [];
88
106
  /**
89
- * Header title text.
107
+ * Header label text.
90
108
  */
91
- this.title = 'Kat';
92
- /**
93
- * Header subtitle text (shown below title with a green status dot).
94
- */
95
- this.subtitle = '';
109
+ this.label = 'Kat';
96
110
  /**
97
111
  * Input placeholder text.
98
112
  */
@@ -101,11 +115,31 @@ let KRChatbot = class KRChatbot extends LitElement {
101
115
  * Whether the chat panel is open. Only relevant in floating mode.
102
116
  */
103
117
  this.opened = false;
118
+ /**
119
+ * Hide the built-in floating toggle button. Use this when an external
120
+ * control (e.g. the site-editor bar) owns opening the chat — the panel is
121
+ * then driven via the `open()` / `close()` / `toggle()` methods.
122
+ */
123
+ this.hideToggle = false;
124
+ /**
125
+ * Layout mode.
126
+ * - `'floating'` (default): the chatbot is a fixed bottom-right widget with a
127
+ * toggle button, and the panel can be dragged and resized. This is the
128
+ * traditional chat-widget appearance.
129
+ * - `'inline'`: the chatbot drops its fixed positioning and fills its parent
130
+ * container (100% width/height, no border radius/shadow, no toggle, no
131
+ * drag/resize). The panel is always open. Use this to embed the chat as a
132
+ * full page or panel region.
133
+ */
134
+ this.variant = 'floating';
104
135
  // --- @state (internal) ---
105
136
  this._inputValue = '';
106
137
  this._isStreaming = false;
107
- this._statusText = '';
108
138
  this._error = '';
139
+ /** Images staged in the composer, before the message is sent. */
140
+ this._pendingInternalFiles = [];
141
+ /** True while a file is being dragged over the panel (shows the drop overlay). */
142
+ this._isDraggingFile = false;
109
143
  this._handleMouseMove = (e) => {
110
144
  if (this._isDragging) {
111
145
  let newLeft = this._dragStartLeft + (e.clientX - this._dragStartX);
@@ -167,12 +201,27 @@ let KRChatbot = class KRChatbot extends LitElement {
167
201
  // --- Lifecycle ---
168
202
  connectedCallback() {
169
203
  super.connectedCallback();
170
- if (this.load) {
171
- this.load().then((messages) => {
172
- if (messages && messages.length) {
173
- this.messages = messages;
174
- }
175
- }).catch(() => { });
204
+ // Default to loading existing messages from the Kodaris Bedrock conversation
205
+ // when the consumer hasn't provided their own `load`. The endpoint returns
206
+ // messages already in KRChatbotMessage shape ({ type, content, images }), so
207
+ // they pass straight through.
208
+ if (!this.load) {
209
+ this.load = () => KRHttp.fetch({
210
+ url: '/api/system/integration/aws/bedrock/getConversationMessages',
211
+ })
212
+ .then((r) => r.json())
213
+ .then((res) => res.data || []);
214
+ }
215
+ this.load().then((messages) => {
216
+ if (messages && messages.length) {
217
+ this.messages = messages;
218
+ }
219
+ }).catch(() => { });
220
+ // Inline mode fills its container and is always open — the floating widget's
221
+ // toggle/drag/resize state (including any persisted layout) doesn't apply.
222
+ if (this.variant === 'inline') {
223
+ this.opened = true;
224
+ return;
176
225
  }
177
226
  this._restoreState();
178
227
  }
@@ -184,9 +233,11 @@ let KRChatbot = class KRChatbot extends LitElement {
184
233
  }
185
234
  document.removeEventListener('mousemove', this._handleMouseMove);
186
235
  document.removeEventListener('mouseup', this._handleMouseUp);
236
+ // Release any object URLs still held by staged attachments.
237
+ this._pendingInternalFiles.forEach((p) => URL.revokeObjectURL(p.previewUrl));
187
238
  }
188
239
  updated(changed) {
189
- if (changed.has('messages') || changed.has('_statusText')) {
240
+ if (changed.has('messages')) {
190
241
  if (!this._userHasScrolledUp && !this._isStreaming && this._messagesEl) {
191
242
  requestAnimationFrame(() => {
192
243
  if (this._messagesEl) {
@@ -210,6 +261,22 @@ let KRChatbot = class KRChatbot extends LitElement {
210
261
  stop() {
211
262
  this._handleStop();
212
263
  }
264
+ /** Open the chat panel. */
265
+ open() {
266
+ if (!this.opened) {
267
+ this._handleToggle();
268
+ }
269
+ }
270
+ /** Close the chat panel. */
271
+ close() {
272
+ if (this.opened) {
273
+ this._handleToggle();
274
+ }
275
+ }
276
+ /** Toggle the chat panel open/closed. */
277
+ toggle() {
278
+ this._handleToggle();
279
+ }
213
280
  // --- Private methods ---
214
281
  /**
215
282
  * Shows a full-viewport transparent overlay during drag/resize operations.
@@ -244,6 +311,10 @@ let KRChatbot = class KRChatbot extends LitElement {
244
311
  }
245
312
  }
246
313
  _handleHeaderMouseDown(e) {
314
+ // Inline mode fills its parent — dragging the panel doesn't apply.
315
+ if (this.variant === 'inline') {
316
+ return;
317
+ }
247
318
  // Ignore clicks on buttons inside the header
248
319
  if (e.target.closest('button')) {
249
320
  return;
@@ -269,6 +340,10 @@ let KRChatbot = class KRChatbot extends LitElement {
269
340
  document.addEventListener('mouseup', this._handleMouseUp);
270
341
  }
271
342
  _handleResizeMouseDown(e) {
343
+ // Inline mode fills its parent — resizing the panel doesn't apply.
344
+ if (this.variant === 'inline') {
345
+ return;
346
+ }
272
347
  if (!this._panelEl) {
273
348
  return;
274
349
  }
@@ -378,14 +453,132 @@ let KRChatbot = class KRChatbot extends LitElement {
378
453
  this._handleSubmit();
379
454
  }
380
455
  }
456
+ // --- Attachments ---
457
+ _openFilePicker() {
458
+ this._fileInputEl?.click();
459
+ }
460
+ _handleFileInputChange(e) {
461
+ const input = e.target;
462
+ if (input.files) {
463
+ this._addFiles(input.files);
464
+ }
465
+ // Reset so picking the same file again re-fires change.
466
+ input.value = '';
467
+ }
468
+ _handlePaste(e) {
469
+ const items = e.clipboardData?.items;
470
+ if (!items) {
471
+ return;
472
+ }
473
+ const files = [];
474
+ for (let i = 0; i < items.length; i++) {
475
+ if (items[i].kind === 'file') {
476
+ const file = items[i].getAsFile();
477
+ if (file) {
478
+ files.push(file);
479
+ }
480
+ }
481
+ }
482
+ if (files.length) {
483
+ e.preventDefault();
484
+ this._addFiles(files);
485
+ }
486
+ }
487
+ _handleDragEnter(e) {
488
+ if (!this._hasFiles(e)) {
489
+ return;
490
+ }
491
+ e.preventDefault();
492
+ this._dragDepth++;
493
+ this._isDraggingFile = true;
494
+ }
495
+ _handleDragOver(e) {
496
+ if (!this._hasFiles(e)) {
497
+ return;
498
+ }
499
+ // Required so the drop event fires.
500
+ e.preventDefault();
501
+ }
502
+ _handleDragLeave(e) {
503
+ if (!this._hasFiles(e)) {
504
+ return;
505
+ }
506
+ this._dragDepth--;
507
+ if (this._dragDepth <= 0) {
508
+ this._dragDepth = 0;
509
+ this._isDraggingFile = false;
510
+ }
511
+ }
512
+ _handleDrop(e) {
513
+ if (!this._hasFiles(e)) {
514
+ return;
515
+ }
516
+ e.preventDefault();
517
+ this._dragDepth = 0;
518
+ this._isDraggingFile = false;
519
+ if (e.dataTransfer?.files) {
520
+ this._addFiles(e.dataTransfer.files);
521
+ }
522
+ }
523
+ _hasFiles(e) {
524
+ return Array.from(e.dataTransfer?.types || []).includes('Files');
525
+ }
526
+ /** Stage a set of image files in the composer (held locally until send). */
527
+ _addFiles(fileList) {
528
+ const files = Array.from(fileList).filter((f) => ACCEPTED_IMAGE_TYPES.includes(f.type));
529
+ if (!files.length) {
530
+ return;
531
+ }
532
+ files.forEach((file) => {
533
+ const pending = {
534
+ localId: 'att-' + this._pendingSeq++,
535
+ file,
536
+ name: file.name,
537
+ previewUrl: URL.createObjectURL(file),
538
+ };
539
+ this._pendingInternalFiles = [...this._pendingInternalFiles, pending];
540
+ });
541
+ }
542
+ _removePending(localId) {
543
+ const target = this._pendingInternalFiles.find((p) => p.localId === localId);
544
+ if (target) {
545
+ URL.revokeObjectURL(target.previewUrl);
546
+ }
547
+ this._pendingInternalFiles = this._pendingInternalFiles.filter((p) => p.localId !== localId);
548
+ }
549
+ _clearPending() {
550
+ this._pendingInternalFiles.forEach((p) => URL.revokeObjectURL(p.previewUrl));
551
+ this._pendingInternalFiles = [];
552
+ }
553
+ /** Open the shared full-screen previewer for a message image. */
554
+ _openImage(img) {
555
+ KRFilePreview.open({
556
+ src: `/api/system/integration/aws/bedrock/conversationImage/${img.internalFileID}/view`,
557
+ name: img.niceName || 'image',
558
+ });
559
+ }
560
+ /** Whether the send button is active: needs text or at least one image. */
561
+ _canSend() {
562
+ return Boolean(this._inputValue.trim()) || this._pendingInternalFiles.length > 0;
563
+ }
381
564
  _handleSubmit() {
382
- if (!this._inputValue.trim() || !this.send || this._isStreaming) {
565
+ if (this._isStreaming) {
383
566
  return;
384
567
  }
385
568
  const messageText = this._inputValue.trim();
569
+ const pending = this._pendingInternalFiles;
570
+ // Allow sending an image with no text, but require at least one of them.
571
+ if (!messageText && !pending.length) {
572
+ return;
573
+ }
574
+ // The raw files sent to the backend. Sent images are shown once the
575
+ // conversation reloads and they come back as stored internal files; the
576
+ // just-sent message itself carries text only.
577
+ const internalFiles = pending.map((p) => p.file);
386
578
  const submitEvent = new CustomEvent('message-submit', {
387
579
  detail: {
388
580
  message: messageText,
581
+ internalFiles,
389
582
  },
390
583
  bubbles: true,
391
584
  composed: true,
@@ -395,24 +588,27 @@ let KRChatbot = class KRChatbot extends LitElement {
395
588
  if (submitEvent.defaultPrevented) {
396
589
  return;
397
590
  }
398
- this.messages = [...this.messages, {
399
- role: 'user',
400
- content: messageText,
401
- }];
591
+ const userMessage = {
592
+ type: 'user',
593
+ content: messageText,
594
+ };
595
+ this.messages = [...this.messages, userMessage];
402
596
  this._inputValue = '';
403
597
  this._error = '';
404
598
  this._userHasScrolledUp = false;
599
+ // Clear the composer and release its preview object URLs.
600
+ this._pendingInternalFiles.forEach((p) => URL.revokeObjectURL(p.previewUrl));
601
+ this._pendingInternalFiles = [];
405
602
  // Reset textarea height
406
603
  if (this._inputEl) {
407
604
  this._inputEl.style.height = 'auto';
408
605
  }
409
606
  // Add empty assistant message to stream into
410
607
  this.messages = [...this.messages, {
411
- role: 'assistant',
608
+ type: 'assistant',
412
609
  content: '',
413
610
  }];
414
611
  this._isStreaming = true;
415
- this._statusText = '';
416
612
  // Scroll user's message to the top (Gemini-style).
417
613
  // Set min-height on the assistant message = container height so there's enough
418
614
  // scroll room below the user message to push it to the top.
@@ -434,9 +630,34 @@ let KRChatbot = class KRChatbot extends LitElement {
434
630
  this._messagesEl.scrollTop = userMsgEl.offsetTop - this._messagesEl.offsetTop - 24;
435
631
  }
436
632
  });
633
+ // Default to streaming from the Kodaris Bedrock conversation endpoints when
634
+ // the consumer hasn't provided their own `send`.
635
+ if (!this.send) {
636
+ this.send = (params) => {
637
+ // With images, post text + files as multipart; without, post plain JSON.
638
+ if (params.internalFiles.length) {
639
+ const form = new FormData();
640
+ form.append('userText', params.message);
641
+ params.internalFiles.forEach((file) => form.append('images', file));
642
+ return KRHttp.fetch({
643
+ url: '/api/system/integration/aws/bedrock/stream/invokeModelForConversationWithImages',
644
+ method: 'POST',
645
+ headers: { Accept: 'text/event-stream' },
646
+ body: form,
647
+ });
648
+ }
649
+ return KRHttp.fetch({
650
+ url: '/api/system/integration/aws/bedrock/stream/invokeModelForConversationWithState',
651
+ method: 'POST',
652
+ headers: { Accept: 'text/event-stream' },
653
+ body: { userText: params.message },
654
+ });
655
+ };
656
+ }
437
657
  this.send({
438
658
  message: messageText,
439
659
  messages: this.messages.slice(0, -1),
660
+ internalFiles,
440
661
  }).then((response) => {
441
662
  if (!response.ok) {
442
663
  this._handleStreamError('Request failed: ' + response.status);
@@ -462,11 +683,15 @@ let KRChatbot = class KRChatbot extends LitElement {
462
683
  this._reader = null;
463
684
  }
464
685
  this._isStreaming = false;
465
- this._statusText = '';
466
686
  }
467
687
  _handleClear() {
688
+ // Default to clearing the Kodaris Bedrock conversation state when the consumer
689
+ // hasn't provided their own `clear`.
468
690
  if (!this.clear) {
469
- return;
691
+ this.clear = () => KRHttp.fetch({
692
+ url: '/api/system/integration/aws/bedrock/clearConversationWithState',
693
+ method: 'POST',
694
+ });
470
695
  }
471
696
  this.clear().then(() => {
472
697
  if (this._reader) {
@@ -475,7 +700,6 @@ let KRChatbot = class KRChatbot extends LitElement {
475
700
  }
476
701
  this.messages = [];
477
702
  this._isStreaming = false;
478
- this._statusText = '';
479
703
  this._error = '';
480
704
  this._inputValue = '';
481
705
  this._userHasScrolledUp = false;
@@ -501,7 +725,6 @@ let KRChatbot = class KRChatbot extends LitElement {
501
725
  this._reader.read().then((result) => {
502
726
  if (result.done) {
503
727
  this._isStreaming = false;
504
- this._statusText = '';
505
728
  this._reader = null;
506
729
  return;
507
730
  }
@@ -534,15 +757,10 @@ let KRChatbot = class KRChatbot extends LitElement {
534
757
  bubbles: true,
535
758
  composed: true,
536
759
  }));
537
- if (data.type === 'STATUS') {
538
- if (data.message) {
539
- this._statusText = String(data.message);
540
- }
541
- }
542
- else if (data.type === 'REASONING_PART') {
760
+ if (data.type === 'REASONING_PART') {
543
761
  if (data.message) {
544
762
  const lastIndex = this.messages.length - 1;
545
- if (lastIndex >= 0 && this.messages[lastIndex].role === 'assistant') {
763
+ if (lastIndex >= 0 && this.messages[lastIndex].type === 'assistant') {
546
764
  if (!this.messages[lastIndex].reasoning) {
547
765
  this.messages[lastIndex].reasoning = '';
548
766
  }
@@ -553,9 +771,8 @@ let KRChatbot = class KRChatbot extends LitElement {
553
771
  }
554
772
  else if (data.type === 'RESPONSE_PART') {
555
773
  if (data.message) {
556
- this._statusText = '';
557
774
  const lastIndex = this.messages.length - 1;
558
- if (lastIndex >= 0 && this.messages[lastIndex].role === 'assistant') {
775
+ if (lastIndex >= 0 && this.messages[lastIndex].type === 'assistant') {
559
776
  this.messages[lastIndex].content += String(data.message);
560
777
  this.requestUpdate();
561
778
  }
@@ -566,7 +783,6 @@ let KRChatbot = class KRChatbot extends LitElement {
566
783
  }
567
784
  if (data.finished) {
568
785
  this._isStreaming = false;
569
- this._statusText = '';
570
786
  this._reader = null;
571
787
  }
572
788
  }
@@ -580,7 +796,6 @@ let KRChatbot = class KRChatbot extends LitElement {
580
796
  }
581
797
  _handleStreamError(message) {
582
798
  this._isStreaming = false;
583
- this._statusText = '';
584
799
  this._error = message;
585
800
  if (this._reader) {
586
801
  this._reader.cancel();
@@ -589,7 +804,7 @@ let KRChatbot = class KRChatbot extends LitElement {
589
804
  // Remove the empty assistant message if it has no content
590
805
  if (this.messages.length > 0) {
591
806
  const lastMessage = this.messages[this.messages.length - 1];
592
- if (lastMessage.role === 'assistant' && !lastMessage.content) {
807
+ if (lastMessage.type === 'assistant' && !lastMessage.content) {
593
808
  this.messages = this.messages.slice(0, -1);
594
809
  }
595
810
  }
@@ -597,78 +812,103 @@ let KRChatbot = class KRChatbot extends LitElement {
597
812
  // --- Render ---
598
813
  render() {
599
814
  return html `
600
- <button
601
- class=${classMap({
815
+ ${(this.hideToggle || this.variant === 'inline') ? nothing : html `
816
+ <button
817
+ class=${classMap({
602
818
  'toggle': true,
603
819
  'toggle--hidden': this.opened,
604
820
  })}
605
- @click=${this._handleToggle}
606
- title="Open chat"
607
- >
608
- <svg
609
- viewBox="0 0 24 24"
610
- fill="none"
611
- stroke="currentColor"
612
- stroke-width="2"
613
- stroke-linecap="round"
614
- class="toggle-icon"
821
+ @click=${this._handleToggle}
822
+ title="Open chat"
615
823
  >
616
- <path d="M12 2C6.48 2 2 6.03 2 11c0 2.61 1.19 4.94 3.05 6.55L4 22l4.8-2.4C9.82 19.85 10.88 20 12 20c5.52 0 10-4.03 10-9S17.52 2 12 2z"/>
617
- <path d="M8 11h.01M12 11h.01M16 11h.01" stroke-width="2.5"/>
618
- </svg>
619
- </button>
620
-
621
- <div class=${classMap({
824
+ <svg
825
+ viewBox="0 0 24 24"
826
+ fill="none"
827
+ stroke="currentColor"
828
+ stroke-width="2"
829
+ stroke-linecap="round"
830
+ class="toggle-icon"
831
+ >
832
+ <path d="M12 2C6.48 2 2 6.03 2 11c0 2.61 1.19 4.94 3.05 6.55L4 22l4.8-2.4C9.82 19.85 10.88 20 12 20c5.52 0 10-4.03 10-9S17.52 2 12 2z"/>
833
+ <path d="M8 11h.01M12 11h.01M16 11h.01" stroke-width="2.5"/>
834
+ </svg>
835
+ </button>
836
+ `}
837
+
838
+ <div
839
+ class=${classMap({
622
840
  'panel': true,
623
841
  'panel--opened': this.opened,
624
- })}>
625
- <div
626
- class="resize resize--n"
627
- data-dir="n"
628
- @mousedown=${this._handleResizeMouseDown}
629
- ></div>
630
- <div
631
- class="resize resize--s"
632
- data-dir="s"
633
- @mousedown=${this._handleResizeMouseDown}
634
- ></div>
635
- <div
636
- class="resize resize--w"
637
- data-dir="w"
638
- @mousedown=${this._handleResizeMouseDown}
639
- ></div>
640
- <div
641
- class="resize resize--e"
642
- data-dir="e"
643
- @mousedown=${this._handleResizeMouseDown}
644
- ></div>
645
- <div
646
- class="resize resize--nw"
647
- data-dir="nw"
648
- @mousedown=${this._handleResizeMouseDown}
649
- ></div>
650
- <div
651
- class="resize resize--ne"
652
- data-dir="ne"
653
- @mousedown=${this._handleResizeMouseDown}
654
- ></div>
655
- <div
656
- class="resize resize--sw"
657
- data-dir="sw"
658
- @mousedown=${this._handleResizeMouseDown}
659
- ></div>
660
- <div
661
- class="resize resize--se"
662
- data-dir="se"
663
- @mousedown=${this._handleResizeMouseDown}
664
- ></div>
842
+ })}
843
+ @dragenter=${this._handleDragEnter}
844
+ @dragover=${this._handleDragOver}
845
+ @dragleave=${this._handleDragLeave}
846
+ @drop=${this._handleDrop}
847
+ >
848
+ ${this._isDraggingFile ? html `
849
+ <div class="drop-overlay">
850
+ <svg
851
+ xmlns="http://www.w3.org/2000/svg"
852
+ fill="none"
853
+ viewBox="0 0 24 24"
854
+ stroke-width="1.5"
855
+ stroke="currentColor"
856
+ >
857
+ <path
858
+ stroke-linecap="round"
859
+ stroke-linejoin="round"
860
+ d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"
861
+ />
862
+ </svg>
863
+ <span>Drop images to attach</span>
864
+ </div>
865
+ ` : nothing}
866
+ ${this.variant === 'inline' ? nothing : html `
867
+ <div
868
+ class="resize resize--n"
869
+ data-dir="n"
870
+ @mousedown=${this._handleResizeMouseDown}
871
+ ></div>
872
+ <div
873
+ class="resize resize--s"
874
+ data-dir="s"
875
+ @mousedown=${this._handleResizeMouseDown}
876
+ ></div>
877
+ <div
878
+ class="resize resize--w"
879
+ data-dir="w"
880
+ @mousedown=${this._handleResizeMouseDown}
881
+ ></div>
882
+ <div
883
+ class="resize resize--e"
884
+ data-dir="e"
885
+ @mousedown=${this._handleResizeMouseDown}
886
+ ></div>
887
+ <div
888
+ class="resize resize--nw"
889
+ data-dir="nw"
890
+ @mousedown=${this._handleResizeMouseDown}
891
+ ></div>
892
+ <div
893
+ class="resize resize--ne"
894
+ data-dir="ne"
895
+ @mousedown=${this._handleResizeMouseDown}
896
+ ></div>
897
+ <div
898
+ class="resize resize--sw"
899
+ data-dir="sw"
900
+ @mousedown=${this._handleResizeMouseDown}
901
+ ></div>
902
+ <div
903
+ class="resize resize--se"
904
+ data-dir="se"
905
+ @mousedown=${this._handleResizeMouseDown}
906
+ ></div>
907
+ `}
665
908
  <div class="header" @mousedown=${this._handleHeaderMouseDown}>
666
909
  <div class="header-left">
667
910
  <div class="header-info">
668
- <span class="header-title">${this.title}</span>
669
- ${this.subtitle ? html `
670
- <span class="header-subtitle">${this.subtitle}</span>
671
- ` : nothing}
911
+ <span class="header-title">${this.label}</span>
672
912
  </div>
673
913
  </div>
674
914
  <div class="header-actions">
@@ -694,63 +934,84 @@ let KRChatbot = class KRChatbot extends LitElement {
694
934
  </svg>
695
935
  </button>
696
936
  ` : nothing}
697
- <button
698
- class="header-action"
699
- @click=${this._handleToggle}
700
- title="Close"
701
- >
702
- <svg
703
- xmlns="http://www.w3.org/2000/svg"
704
- fill="none"
705
- viewBox="0 0 24 24"
706
- stroke-width="2"
707
- stroke="currentColor"
708
- class="header-action-icon"
937
+ ${this.variant === 'inline' ? nothing : html `
938
+ <button
939
+ class="header-action"
940
+ @click=${this._handleToggle}
941
+ title="Close"
709
942
  >
710
- <path
711
- stroke-linecap="round"
712
- stroke-linejoin="round"
713
- d="M6 18 18 6M6 6l12 12"
714
- />
715
- </svg>
716
- </button>
943
+ <svg
944
+ xmlns="http://www.w3.org/2000/svg"
945
+ fill="none"
946
+ viewBox="0 0 24 24"
947
+ stroke-width="2"
948
+ stroke="currentColor"
949
+ class="header-action-icon"
950
+ >
951
+ <path
952
+ stroke-linecap="round"
953
+ stroke-linejoin="round"
954
+ d="M6 18 18 6M6 6l12 12"
955
+ />
956
+ </svg>
957
+ </button>
958
+ `}
717
959
  </div>
718
960
  </div>
719
961
 
720
962
  <div class="messages" @scroll=${this._handleMessagesScroll}>
721
- ${this.messages.length === 0 ? html `
722
- <div class="empty">
723
- <span class="empty-text">What can I help you with?</span>
724
- </div>
725
- ` : nothing}
726
- ${this.messages.map((msg) => html `
727
- <div class=${classMap({
963
+ <div class="messages-inner">
964
+ ${this.messages.length === 0 ? html `
965
+ <div class="empty">
966
+ <span class="empty-text">What can I help you with?</span>
967
+ </div>
968
+ ` : nothing}
969
+ ${this.messages.map((msg) => html `
970
+ <div class=${classMap({
728
971
  'message': true,
729
- 'message--user': msg.role === 'user',
730
- 'message--assistant': msg.role === 'assistant',
972
+ 'message--user': msg.type === 'user',
973
+ 'message--assistant': msg.type === 'assistant',
731
974
  })}>
732
- ${msg.reasoning ? html `
733
- <details class="reasoning">
734
- <summary>Thinking</summary>
735
- <div class="reasoning-content">${msg.reasoning}</div>
736
- </details>
737
- ` : nothing}
738
- ${msg.content ? html `
739
- <div class="message-content">
740
- ${msg.role === 'user'
975
+ ${msg.reasoning ? html `
976
+ <details class="reasoning">
977
+ <summary>Thinking</summary>
978
+ <div class="reasoning-content">${msg.reasoning}</div>
979
+ </details>
980
+ ` : nothing}
981
+ ${msg.internalFiles && msg.internalFiles.length ? html `
982
+ <div class="attachments">
983
+ ${msg.internalFiles.map((img) => html `
984
+ <button
985
+ class="attachment"
986
+ type="button"
987
+ title=${img.niceName || 'View image'}
988
+ @click=${() => this._openImage(img)}
989
+ >
990
+ <img
991
+ src="/api/system/integration/aws/bedrock/conversationImage/${img.internalFileID}/view"
992
+ alt=${img.niceName || ''}
993
+ />
994
+ </button>
995
+ `)}
996
+ </div>
997
+ ` : nothing}
998
+ ${msg.content ? html `
999
+ <div class="message-content">
1000
+ ${msg.type === 'user'
741
1001
  ? msg.content
742
1002
  : unsafeHTML(marked.parse(msg.content, { breaks: true }))}
743
- </div>
744
- ` : nothing}
745
- ${msg.role === 'assistant' && this._isStreaming && msg === this.messages[this.messages.length - 1] ? html `
746
- <div class="typing">
747
- <span></span>
748
- <span></span>
749
- <span></span>
750
- </div>
751
- ` : nothing}
752
- </div>
753
- `)}
1003
+ </div>
1004
+ ` : nothing}
1005
+ ${msg.type === 'assistant' && this._isStreaming && msg === this.messages[this.messages.length - 1] ? html `
1006
+ <div class="typing">
1007
+ <span></span>
1008
+ <span></span>
1009
+ <span></span>
1010
+ </div>
1011
+ ` : nothing}
1012
+ </div>
1013
+ `)}
1014
+ </div>
754
1015
  </div>
755
1016
 
756
1017
  ${this._error ? html `
@@ -776,8 +1037,48 @@ let KRChatbot = class KRChatbot extends LitElement {
776
1037
  ` : nothing}
777
1038
 
778
1039
  <div class="footer">
1040
+ ${this._pendingInternalFiles.length ? html `
1041
+ <div class="composer-attachments">
1042
+ ${repeat(this._pendingInternalFiles, (p) => p.localId, (p) => html `
1043
+ <div class="chip">
1044
+ <img src=${p.previewUrl} alt=${p.name} />
1045
+ <button
1046
+ class="chip-remove"
1047
+ title="Remove"
1048
+ @click=${() => this._removePending(p.localId)}
1049
+ >
1050
+ <svg
1051
+ xmlns="http://www.w3.org/2000/svg"
1052
+ fill="none"
1053
+ viewBox="0 0 24 24"
1054
+ stroke-width="3"
1055
+ stroke="currentColor"
1056
+ >
1057
+ <path
1058
+ stroke-linecap="round"
1059
+ stroke-linejoin="round"
1060
+ d="M6 18 18 6M6 6l12 12"
1061
+ />
1062
+ </svg>
1063
+ </button>
1064
+ </div>
1065
+ `)}
1066
+ </div>
1067
+ ` : nothing}
1068
+ <input
1069
+ class="file-input"
1070
+ type="file"
1071
+ accept="image/png,image/jpeg,image/gif,image/webp"
1072
+ multiple
1073
+ hidden
1074
+ @change=${this._handleFileInputChange}
1075
+ />
779
1076
  <div class="input-wrapper">
780
- <button class="input-plus" title="Attach">+</button>
1077
+ <button
1078
+ class="input-plus"
1079
+ title="Attach image"
1080
+ @click=${this._openFilePicker}
1081
+ >+</button>
781
1082
  <textarea
782
1083
  class="input"
783
1084
  .value=${this._inputValue}
@@ -785,6 +1086,7 @@ let KRChatbot = class KRChatbot extends LitElement {
785
1086
  rows="1"
786
1087
  @input=${this._handleInputChange}
787
1088
  @keydown=${this._handleKeyDown}
1089
+ @paste=${this._handlePaste}
788
1090
  ></textarea>
789
1091
  ${this._isStreaming ? html `
790
1092
  <button
@@ -811,7 +1113,7 @@ let KRChatbot = class KRChatbot extends LitElement {
811
1113
  <button
812
1114
  class=${classMap({
813
1115
  'send': true,
814
- 'send--disabled': !this._inputValue.trim(),
1116
+ 'send--disabled': !this._canSend(),
815
1117
  })}
816
1118
  @click=${this._handleSubmit}
817
1119
  title="Send message"
@@ -926,6 +1228,28 @@ KRChatbot.styles = css `
926
1228
  display: flex;
927
1229
  }
928
1230
 
1231
+ /* Inline mode: fill the parent container instead of floating bottom-right. */
1232
+ :host([variant='inline']) {
1233
+ position: static;
1234
+ bottom: auto;
1235
+ right: auto;
1236
+ z-index: auto;
1237
+ height: 100%;
1238
+ }
1239
+
1240
+ :host([variant='inline']) .panel {
1241
+ position: relative;
1242
+ bottom: auto;
1243
+ right: auto;
1244
+ width: 100%;
1245
+ height: 100%;
1246
+ max-height: none;
1247
+ border-radius: 0;
1248
+ box-shadow: none;
1249
+ border: none;
1250
+ animation: none;
1251
+ }
1252
+
929
1253
  @keyframes chatbot-panel-in {
930
1254
  from {
931
1255
  opacity: 0;
@@ -956,7 +1280,7 @@ KRChatbot.styles = css `
956
1280
  gap: 10px;
957
1281
  }
958
1282
 
959
- .header-info {
1283
+ .header-info {
960
1284
  display: flex;
961
1285
  flex-direction: column;
962
1286
  }
@@ -966,22 +1290,6 @@ KRChatbot.styles = css `
966
1290
  font-weight: 600;
967
1291
  }
968
1292
 
969
- .header-subtitle {
970
- color: rgba(255, 255, 255, 0.5);
971
- font-size: 11px;
972
- display: flex;
973
- align-items: center;
974
- gap: 4px;
975
- }
976
-
977
- .header-subtitle::before {
978
- content: '';
979
- width: 5px;
980
- height: 5px;
981
- background: var(--kr-chatbot-success);
982
- border-radius: 50%;
983
- }
984
-
985
1293
  .header-actions {
986
1294
  display: flex;
987
1295
  align-items: center;
@@ -1013,17 +1321,29 @@ KRChatbot.styles = css `
1013
1321
  }
1014
1322
 
1015
1323
  /* Messages */
1324
+ /* Full-width scroll container — keeps the scrollbar at the panel edge. */
1016
1325
  .messages {
1017
1326
  flex: 1;
1018
1327
  overflow-y: auto;
1019
- padding: 32px;
1020
1328
  display: flex;
1021
1329
  flex-direction: column;
1022
- gap: 24px;
1023
1330
  scroll-behavior: smooth;
1024
1331
  background: white;
1025
1332
  }
1026
1333
 
1334
+ /* Centered, width-capped column that actually holds the messages. */
1335
+ .messages-inner {
1336
+ flex: 1;
1337
+ display: flex;
1338
+ flex-direction: column;
1339
+ gap: 24px;
1340
+ padding: 32px;
1341
+ width: 100%;
1342
+ max-width: 768px;
1343
+ margin-left: auto;
1344
+ margin-right: auto;
1345
+ }
1346
+
1027
1347
  .messages::-webkit-scrollbar {
1028
1348
  width: 4px;
1029
1349
  }
@@ -1047,7 +1367,7 @@ KRChatbot.styles = css `
1047
1367
  gap: 8px;
1048
1368
  }
1049
1369
 
1050
- .empty-text {
1370
+ .empty-text {
1051
1371
  font-size: 14px;
1052
1372
  color: rgb(0 0 0 / 80%);
1053
1373
  }
@@ -1159,7 +1479,7 @@ KRChatbot.styles = css `
1159
1479
  word-wrap: break-word;
1160
1480
  }
1161
1481
 
1162
- .typing {
1482
+ .typing {
1163
1483
  display: flex;
1164
1484
  gap: 4px;
1165
1485
  padding: 4px 0;
@@ -1227,6 +1547,15 @@ KRChatbot.styles = css `
1227
1547
  padding: 16px 16px 16px;
1228
1548
  flex-shrink: 0;
1229
1549
  background: white;
1550
+ width: 100%;
1551
+ max-width: 768px;
1552
+ margin-left: auto;
1553
+ margin-right: auto;
1554
+ }
1555
+
1556
+ /* Inline mode: keep the composer clear of the bottom edge. */
1557
+ :host([variant='inline']) .footer {
1558
+ padding-bottom: 40px;
1230
1559
  }
1231
1560
 
1232
1561
  .input-wrapper {
@@ -1245,6 +1574,15 @@ KRChatbot.styles = css `
1245
1574
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.04);
1246
1575
  }
1247
1576
 
1577
+ /* Inline mode: keep the border and add a soft floating shadow (ChatGPT/Gemini style). */
1578
+ :host([variant='inline']) .input-wrapper {
1579
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06), 0 8px 24px rgba(0, 0, 0, 0.1);
1580
+ }
1581
+
1582
+ :host([variant='inline']) .input-wrapper:focus-within {
1583
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 12px 32px rgba(0, 0, 0, 0.14);
1584
+ }
1585
+
1248
1586
  .input-plus {
1249
1587
  width: 34px;
1250
1588
  height: 34px;
@@ -1352,6 +1690,12 @@ KRChatbot.styles = css `
1352
1690
  cursor: grabbing;
1353
1691
  }
1354
1692
 
1693
+ /* Inline mode isn't draggable — don't show the grab/hand cursor. */
1694
+ :host([variant='inline']) .header,
1695
+ :host([variant='inline']) .header:active {
1696
+ cursor: default;
1697
+ }
1698
+
1355
1699
  /* Drag / Resize states */
1356
1700
  .panel--dragging,
1357
1701
  .panel--resizing {
@@ -1378,6 +1722,116 @@ KRChatbot.styles = css `
1378
1722
  .resize--sw { bottom: -4px; left: -4px; width: 16px; height: 16px; cursor: sw-resize; }
1379
1723
  .resize--se { bottom: -4px; right: -4px; width: 16px; height: 16px; cursor: se-resize; }
1380
1724
 
1725
+ /* Attachment thumbnails inside a sent message bubble */
1726
+ .attachments {
1727
+ display: flex;
1728
+ flex-wrap: wrap;
1729
+ gap: 8px;
1730
+ }
1731
+
1732
+ .message--user .attachments {
1733
+ justify-content: flex-end;
1734
+ }
1735
+
1736
+ .attachment {
1737
+ width: 120px;
1738
+ height: 120px;
1739
+ border-radius: 12px;
1740
+ overflow: hidden;
1741
+ background: #f2f2f4;
1742
+ border: 1px solid rgba(0, 0, 0, 0.08);
1743
+ cursor: zoom-in;
1744
+ flex-shrink: 0;
1745
+ padding: 0;
1746
+ }
1747
+
1748
+ .attachment img {
1749
+ width: 100%;
1750
+ height: 100%;
1751
+ object-fit: cover;
1752
+ display: block;
1753
+ }
1754
+
1755
+ /* Composer attachment chips (pre-send) */
1756
+ .composer-attachments {
1757
+ display: flex;
1758
+ flex-wrap: wrap;
1759
+ gap: 8px;
1760
+ padding: 0 6px 10px;
1761
+ }
1762
+
1763
+ .chip {
1764
+ position: relative;
1765
+ width: 56px;
1766
+ height: 56px;
1767
+ border-radius: 10px;
1768
+ overflow: hidden;
1769
+ background: #f2f2f4;
1770
+ border: 1px solid var(--kr-chatbot-border);
1771
+ flex-shrink: 0;
1772
+ }
1773
+
1774
+ .chip img {
1775
+ width: 100%;
1776
+ height: 100%;
1777
+ object-fit: cover;
1778
+ display: block;
1779
+ }
1780
+
1781
+ .chip-remove {
1782
+ position: absolute;
1783
+ top: 2px;
1784
+ right: 2px;
1785
+ width: 18px;
1786
+ height: 18px;
1787
+ border-radius: 50%;
1788
+ border: none;
1789
+ background: rgba(0, 0, 0, 0.6);
1790
+ color: #fff;
1791
+ cursor: pointer;
1792
+ display: flex;
1793
+ align-items: center;
1794
+ justify-content: center;
1795
+ padding: 0;
1796
+ line-height: 1;
1797
+ }
1798
+
1799
+ .chip-remove:hover {
1800
+ background: rgba(0, 0, 0, 0.8);
1801
+ }
1802
+
1803
+ .chip-remove svg {
1804
+ width: 10px;
1805
+ height: 10px;
1806
+ }
1807
+
1808
+ /* Drag-and-drop overlay */
1809
+ .drop-overlay {
1810
+ position: absolute;
1811
+ inset: 0;
1812
+ z-index: 20;
1813
+ display: flex;
1814
+ align-items: center;
1815
+ justify-content: center;
1816
+ flex-direction: column;
1817
+ gap: 10px;
1818
+ /* Frosted, mostly-opaque surface so the chat content behind is masked
1819
+ and doesn't clash with the overlay label. */
1820
+ background: rgba(255, 255, 255, 0.92);
1821
+ backdrop-filter: blur(3px);
1822
+ border: 2px dashed var(--kr-chatbot-primary);
1823
+ border-radius: 16px;
1824
+ color: var(--kr-chatbot-primary);
1825
+ font-weight: 600;
1826
+ font-size: 15px;
1827
+ pointer-events: none;
1828
+ }
1829
+
1830
+ .drop-overlay svg {
1831
+ width: 32px;
1832
+ height: 32px;
1833
+ }
1834
+
1381
1835
  `;
1382
1836
  __decorate([
1383
1837
  property({ attribute: false })
@@ -1393,10 +1847,7 @@ __decorate([
1393
1847
  ], KRChatbot.prototype, "messages", void 0);
1394
1848
  __decorate([
1395
1849
  property({ type: String })
1396
- ], KRChatbot.prototype, "title", void 0);
1397
- __decorate([
1398
- property({ type: String })
1399
- ], KRChatbot.prototype, "subtitle", void 0);
1850
+ ], KRChatbot.prototype, "label", void 0);
1400
1851
  __decorate([
1401
1852
  property({ type: String })
1402
1853
  ], KRChatbot.prototype, "placeholder", void 0);
@@ -1406,6 +1857,19 @@ __decorate([
1406
1857
  reflect: true
1407
1858
  })
1408
1859
  ], KRChatbot.prototype, "opened", void 0);
1860
+ __decorate([
1861
+ property({
1862
+ type: Boolean,
1863
+ attribute: 'hide-toggle',
1864
+ reflect: true
1865
+ })
1866
+ ], KRChatbot.prototype, "hideToggle", void 0);
1867
+ __decorate([
1868
+ property({
1869
+ type: String,
1870
+ reflect: true
1871
+ })
1872
+ ], KRChatbot.prototype, "variant", void 0);
1409
1873
  __decorate([
1410
1874
  state()
1411
1875
  ], KRChatbot.prototype, "_inputValue", void 0);
@@ -1414,10 +1878,13 @@ __decorate([
1414
1878
  ], KRChatbot.prototype, "_isStreaming", void 0);
1415
1879
  __decorate([
1416
1880
  state()
1417
- ], KRChatbot.prototype, "_statusText", void 0);
1881
+ ], KRChatbot.prototype, "_error", void 0);
1418
1882
  __decorate([
1419
1883
  state()
1420
- ], KRChatbot.prototype, "_error", void 0);
1884
+ ], KRChatbot.prototype, "_pendingInternalFiles", void 0);
1885
+ __decorate([
1886
+ state()
1887
+ ], KRChatbot.prototype, "_isDraggingFile", void 0);
1421
1888
  __decorate([
1422
1889
  query('.panel')
1423
1890
  ], KRChatbot.prototype, "_panelEl", void 0);
@@ -1427,6 +1894,9 @@ __decorate([
1427
1894
  __decorate([
1428
1895
  query('.input')
1429
1896
  ], KRChatbot.prototype, "_inputEl", void 0);
1897
+ __decorate([
1898
+ query('.file-input')
1899
+ ], KRChatbot.prototype, "_fileInputEl", void 0);
1430
1900
  KRChatbot = __decorate([
1431
1901
  customElement('kr-chatbot')
1432
1902
  ], KRChatbot);