yaml-flow 2.6.1 → 2.8.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.
@@ -0,0 +1,296 @@
1
+ // ingest-board.js — Ingest Board: a LiveCard Board type for batch ingest UIs
2
+ //
3
+ // Pure component. Zero I/O (no fetch, no EventSource, no polling).
4
+ // All side-effects delegated to host via callbacks.
5
+ //
6
+ // API:
7
+ // const ib = IngestBoard.create(containerEl, opts)
8
+ //
9
+ // ib.setBatches(batches) — rebuild board from batch array
10
+ // ib.getChat(boardId) — get chat element API for a card
11
+ // ib.getFileUpload(boardId) — get file-upload element API for a card
12
+ // ib.getActions(boardId) — get actions element API for a card
13
+ // ib.showChatModal(boardId, messages) — open Bootstrap modal with chat history
14
+ // ib.destroy()
15
+ //
16
+ // Required opts:
17
+ // onSend(boardId, { text, files }) — host handles: upload files + message → call getChat().appendMessage() with results
18
+ // onConfirm(boardId) — host handles: POST confirm → SSE → update board
19
+ // onDiscard(boardId) — host handles: POST discard → call ib.setBatches()
20
+ //
21
+ // Optional opts:
22
+ // onViewChat(boardId) — host handles: fetch chat → call ib.showChatModal()
23
+ // onRefresh() — host handles: fetch batches → call ib.setBatches()
24
+ // compact — smaller card columns (default: false)
25
+ // engine — existing LiveCard engine (one is created if omitted)
26
+ // markdown — markdown renderer fn
27
+ // sanitize — HTML sanitizer fn
28
+
29
+ // eslint-disable-next-line no-unused-vars
30
+ var IngestBoard = (function () {
31
+ 'use strict';
32
+
33
+ function create(containerEl, opts) {
34
+ opts = opts || {};
35
+ if (!opts.onSend) throw new Error('IngestBoard: opts.onSend is required');
36
+ if (!opts.onConfirm) throw new Error('IngestBoard: opts.onConfirm is required');
37
+ if (!opts.onDiscard) throw new Error('IngestBoard: opts.onDiscard is required');
38
+
39
+ const compact = opts.compact || false;
40
+ const onSend = opts.onSend;
41
+ const onConfirm = opts.onConfirm;
42
+ const onDiscard = opts.onDiscard;
43
+ const onViewChat = opts.onViewChat || null;
44
+ const onRefresh = opts.onRefresh || null;
45
+ const mdFn = opts.markdown || (typeof marked !== 'undefined' ? function (t) { return marked.parse(t); } : null);
46
+ const sanFn = opts.sanitize || (typeof DOMPurify !== 'undefined' ? function (h) { return DOMPurify.sanitize(h); } : null);
47
+
48
+ let board = null;
49
+ const nodes = {}; // id → node
50
+
51
+ // ---- Engine ----
52
+
53
+ const engine = opts.engine || LiveCard.init({
54
+ resolve: function (id) { return nodes[id]; },
55
+ onPatch: function () {},
56
+ onPatchState: function () {},
57
+ onRefresh: onRefresh || function () {},
58
+ onAction: handleAction,
59
+ markdown: mdFn,
60
+ sanitize: sanFn,
61
+ });
62
+
63
+ // ---- Action dispatcher (pure — delegates to host callbacks) ----
64
+
65
+ function handleAction(nodeId, actionType, payload) {
66
+ if (actionType === 'chat-send') {
67
+ // For the "new" card, boardId is null until host creates one
68
+ var boardId = nodeId === '__new__' ? null : nodeId;
69
+ onSend(boardId, { text: payload.text, files: payload.files });
70
+ } else if (actionType === 'action') {
71
+ if (payload.buttonId === 'confirm') onConfirm(nodeId);
72
+ else if (payload.buttonId === 'discard') onDiscard(nodeId);
73
+ else if (payload.buttonId === 'view-chat' && onViewChat) onViewChat(nodeId);
74
+ }
75
+ }
76
+
77
+ // ---- Node builders ----
78
+
79
+ function buildActiveNode(batch) {
80
+ return {
81
+ id: batch.id,
82
+ type: 'card',
83
+ meta: { title: batch.id, tags: [batch.status === 'open-items' ? 'open-items' : 'ready'] },
84
+ state: {
85
+ status: batch.processing ? 'loading' : 'fresh',
86
+ messages: batch.chat || [],
87
+ files: batch.files || [],
88
+ batchStatus: batch.status,
89
+ },
90
+ view: {
91
+ elements: [
92
+ {
93
+ id: 'chat',
94
+ kind: 'chat',
95
+ data: { bind: 'state.messages', fileAttach: true, placeholder: 'Add files or type a message...' }
96
+ },
97
+ {
98
+ id: 'actions',
99
+ kind: 'actions',
100
+ data: {
101
+ buttons: [
102
+ { id: 'confirm', label: 'Confirm & Merge', style: 'success', disabled: batch.status !== 'ready' || !!batch.processing },
103
+ { id: 'discard', label: 'Discard', style: 'outline-danger' }
104
+ ]
105
+ }
106
+ }
107
+ ],
108
+ layout: { board: { col: compact ? 6 : 8, order: 0 } },
109
+ features: { refresh: false }
110
+ }
111
+ };
112
+ }
113
+
114
+ function buildCompletedNode(batch, order) {
115
+ var elements = [];
116
+
117
+ if (batch.files && batch.files.length) {
118
+ elements.push({
119
+ id: 'files',
120
+ kind: 'file-upload',
121
+ data: { bind: 'state.files', upload: false }
122
+ });
123
+ }
124
+
125
+ if (batch.summary) {
126
+ elements.push({
127
+ id: 'summary',
128
+ kind: 'text',
129
+ data: { bind: 'state.summary', style: 'muted' }
130
+ });
131
+ }
132
+
133
+ if (batch.chatCount > 0 && onViewChat) {
134
+ elements.push({
135
+ id: 'card-actions',
136
+ kind: 'actions',
137
+ data: { buttons: [{ id: 'view-chat', label: '\uD83D\uDCAC ' + batch.chatCount + ' message(s)', style: 'outline-secondary' }] }
138
+ });
139
+ }
140
+
141
+ return {
142
+ id: batch.id,
143
+ type: 'card',
144
+ meta: { title: batch.id, tags: ['confirmed'] },
145
+ state: {
146
+ status: 'fresh',
147
+ files: batch.files || [],
148
+ summary: batch.summary || '',
149
+ },
150
+ view: {
151
+ elements: elements,
152
+ layout: { board: { col: compact ? 6 : 4, order: order } },
153
+ features: { refresh: false }
154
+ }
155
+ };
156
+ }
157
+
158
+ function buildNewNode() {
159
+ return {
160
+ id: '__new__',
161
+ type: 'card',
162
+ meta: { title: 'New Batch', tags: ['new'] },
163
+ state: { status: 'fresh', messages: [] },
164
+ view: {
165
+ elements: [
166
+ {
167
+ id: 'chat',
168
+ kind: 'chat',
169
+ data: { bind: 'state.messages', fileAttach: true, placeholder: 'Add files or type a message...' }
170
+ }
171
+ ],
172
+ layout: { board: { col: compact ? 6 : 8, order: -1 } },
173
+ features: { refresh: false }
174
+ }
175
+ };
176
+ }
177
+
178
+ // ---- setBatches — rebuild the board from data ----
179
+
180
+ function setBatches(batches) {
181
+ Object.keys(nodes).forEach(function (k) { delete nodes[k]; });
182
+
183
+ var hasActive = batches.some(function (b) { return b.status === 'ready' || b.status === 'open-items'; });
184
+ var allNodes = [];
185
+
186
+ if (!hasActive) {
187
+ var nn = buildNewNode();
188
+ nodes.__new__ = nn;
189
+ allNodes.push(nn);
190
+ }
191
+
192
+ var order = 1;
193
+ batches.forEach(function (b) {
194
+ var isActive = b.status === 'ready' || b.status === 'open-items';
195
+ var n = isActive ? buildActiveNode(b) : buildCompletedNode(b, order++);
196
+ nodes[b.id] = n;
197
+ allNodes.push(n);
198
+ });
199
+
200
+ if (board) board.destroy();
201
+ board = LiveCard.Board(engine, containerEl, {
202
+ nodes: allNodes,
203
+ mode: 'board',
204
+ showNotes: false,
205
+ showChat: false,
206
+ });
207
+ }
208
+
209
+ // ---- Element accessors (host uses these to push data in) ----
210
+
211
+ function getChat(boardId) {
212
+ var el = engine.getElement(boardId, 'chat');
213
+ return el && el._chat || null;
214
+ }
215
+
216
+ function getFileUpload(boardId) {
217
+ var el = engine.getElement(boardId, 'files');
218
+ return el && el._fileUpload || null;
219
+ }
220
+
221
+ function getActions(boardId) {
222
+ var el = engine.getElement(boardId, 'actions') || engine.getElement(boardId, 'card-actions');
223
+ return el && el._actions || null;
224
+ }
225
+
226
+ // ---- Chat modal (pure — caller passes messages) ----
227
+
228
+ function showChatModal(boardId, messages) {
229
+ var existing = document.getElementById('lc-chat-modal');
230
+ if (existing) existing.remove();
231
+ var bd = document.querySelector('.modal-backdrop');
232
+ if (bd) bd.remove();
233
+
234
+ var _e = function (t) { var d = document.createElement('div'); d.textContent = t; return d.innerHTML; };
235
+
236
+ var wrap = document.createElement('div');
237
+ wrap.innerHTML =
238
+ '<div class="modal fade" id="lc-chat-modal" tabindex="-1">' +
239
+ '<div class="modal-dialog modal-dialog-scrollable">' +
240
+ '<div class="modal-content">' +
241
+ '<div class="modal-header">' +
242
+ '<h6 class="modal-title">' + _e(boardId) + '</h6>' +
243
+ '<button type="button" class="btn-close" data-bs-dismiss="modal"></button>' +
244
+ '</div>' +
245
+ '<div class="modal-body"><div class="lc-chat-body" id="lc-chat-modal-body" style="max-height:none"></div></div>' +
246
+ '</div>' +
247
+ '</div>' +
248
+ '</div>';
249
+ document.body.appendChild(wrap.firstElementChild);
250
+
251
+ var bodyEl = document.getElementById('lc-chat-modal-body');
252
+ messages.forEach(function (msg) {
253
+ var bub = document.createElement('div');
254
+ var rc = msg.role === 'user' ? 'lc-chat-bubble-user'
255
+ : msg.role === 'assistant' ? 'lc-chat-bubble-assistant'
256
+ : 'lc-chat-bubble-system';
257
+ bub.className = 'lc-chat-bubble ' + rc;
258
+ if (msg.role === 'assistant' && mdFn) {
259
+ var html = mdFn(msg.text);
260
+ if (sanFn) html = sanFn(html);
261
+ bub.innerHTML = html;
262
+ } else {
263
+ bub.textContent = msg.text;
264
+ }
265
+ bodyEl.appendChild(bub);
266
+ });
267
+
268
+ var modal = new bootstrap.Modal(document.getElementById('lc-chat-modal'));
269
+ modal.show();
270
+ document.getElementById('lc-chat-modal').addEventListener('hidden.bs.modal', function () {
271
+ document.getElementById('lc-chat-modal').remove();
272
+ });
273
+ }
274
+
275
+ // ---- Lifecycle ----
276
+
277
+ function destroy() {
278
+ if (board) board.destroy();
279
+ board = null;
280
+ Object.keys(nodes).forEach(function (k) { delete nodes[k]; });
281
+ }
282
+
283
+ return {
284
+ setBatches: setBatches,
285
+ getChat: getChat,
286
+ getFileUpload: getFileUpload,
287
+ getActions: getActions,
288
+ showChatModal: showChatModal,
289
+ destroy: destroy,
290
+ get engine() { return engine; },
291
+ get board() { return board; },
292
+ };
293
+ }
294
+
295
+ return { create: create };
296
+ })();
@@ -51,10 +51,25 @@ var LiveCard = (function () {
51
51
  .lc-todo-item:last-child { border-bottom:none; }
52
52
  .lc-notes-preview { min-height:80px; }
53
53
  .lc-source-pill { display:inline-flex; align-items:center; gap:0.5rem; padding:0.5rem 0.75rem; border-radius:2rem; font-size:0.8rem; background:var(--bs-light,#f8f9fa); border:1px solid var(--bs-border-color,#dee2e6); }
54
+ .lc-dropzone { border:2px dashed var(--bs-border-color,#dee2e6); border-radius:.5rem; padding:1.5rem; text-align:center; cursor:pointer; transition:border-color .15s,background .15s; }
55
+ .lc-dropzone:hover { border-color:var(--bs-primary,#0d6efd); }
56
+ .lc-dropzone.lc-drag-over { border-color:var(--bs-primary,#0d6efd); background:rgba(13,110,253,.05); }
57
+ .lc-dropzone.lc-disabled { pointer-events:none; opacity:.5; }
58
+ .lc-staged-file { display:flex; align-items:center; gap:.5rem; padding:.125rem 0; }
59
+ .lc-chat-el { display:flex; flex-direction:column; }
60
+ .lc-chat-body { flex:1; overflow-y:auto; max-height:300px; padding:.25rem; }
61
+ .lc-chat-bubble { padding:.375rem .625rem; margin:.25rem 0; border-radius:.75rem; max-width:85%; word-wrap:break-word; font-size:.875rem; }
62
+ .lc-chat-bubble-user { background:var(--bs-primary-bg-subtle,#cfe2ff); margin-left:auto; }
63
+ .lc-chat-bubble-assistant { background:var(--bs-light,#f8f9fa); }
64
+ .lc-chat-bubble-system { background:transparent; color:var(--bs-secondary,#6c757d); font-style:italic; text-align:center; max-width:100%; font-size:.8rem; }
65
+ .lc-chat-input-bar { display:flex; gap:.25rem; align-items:center; }
66
+ .lc-chat-processing { display:flex; align-items:center; gap:.5rem; padding:.25rem .5rem; color:var(--bs-secondary,#6c757d); font-size:.8rem; }
54
67
  @media (max-width:576px) {
55
68
  .lc-metric-value { font-size:1.5rem; }
56
69
  .lc-chart-wrap { min-height:150px; }
57
70
  .lc-chat-msg { max-width:95%; }
71
+ .lc-chat-body { max-height:200px; }
72
+ .lc-chat-bubble { max-width:95%; }
58
73
  }
59
74
  `;
60
75
  document.head.appendChild(s);
@@ -150,6 +165,7 @@ var LiveCard = (function () {
150
165
  markdown: config.markdown || null,
151
166
  sanitize: config.sanitize || null,
152
167
  chartLib: config.chartLib || null,
168
+ onAction: config.onAction || function () {},
153
169
  };
154
170
 
155
171
  const _cleanup = {}; // nodeId → { ac, timers, charts, unsubs }
@@ -671,6 +687,276 @@ var LiveCard = (function () {
671
687
  el.innerHTML = `<pre class="small mb-0">${_esc(JSON.stringify(data, null, 2))}</pre>`;
672
688
  }
673
689
 
690
+ // ---- file-upload ----
691
+
692
+ function _renderFileUpload(data, el, elemDef, node) {
693
+ const cleanup = _getCleanup(node.id);
694
+ const signal = cleanup.ac.signal;
695
+ const ed = elemDef.data || {};
696
+ const uploaded = Array.isArray(data) ? data : [];
697
+ const showUpload = ed.upload !== false;
698
+ const accept = ed.accept || ['.txt','.csv','.md','.json','.html','.xml','.pdf','.xlsx','.docx','.pptx','.png','.jpg','.jpeg'];
699
+ const acceptSet = new Set(accept.map(e => e.toLowerCase()));
700
+ const multiple = ed.multiple !== false;
701
+ const placeholder = ed.placeholder || 'Drop files here or click to browse';
702
+ const uid = 'lc-fu-' + (elemDef.id || Math.random().toString(36).slice(2, 8));
703
+
704
+ let stagedFiles = el._stagedFiles || [];
705
+ el._stagedFiles = stagedFiles;
706
+
707
+ let h = '';
708
+
709
+ // Drop zone
710
+ if (showUpload) {
711
+ h += `<div class="lc-dropzone mb-2" id="${uid}-dz">`;
712
+ h += '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="text-muted mb-1"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>';
713
+ h += `<div class="small text-muted">${_esc(placeholder)}</div>`;
714
+ h += `<input type="file" id="${uid}-fi" class="d-none"${multiple ? ' multiple' : ''} accept="${accept.join(',')}">`;
715
+ h += '</div>';
716
+ h += `<div id="${uid}-staged"></div>`;
717
+ }
718
+
719
+ // Uploaded files list
720
+ if (uploaded.length) {
721
+ h += '<div class="lc-uploaded-files">';
722
+ uploaded.forEach(f => {
723
+ const name = typeof f === 'string' ? f : (f.name || '');
724
+ const url = typeof f === 'string' ? null : f.url;
725
+ h += '<div class="d-flex align-items-center gap-1 small mb-1">';
726
+ h += '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>';
727
+ if (url) h += `<a href="${_esc(url)}" class="text-truncate" target="_blank" download>${_esc(name)}</a>`;
728
+ else h += `<span class="text-truncate">${_esc(name)}</span>`;
729
+ h += '</div>';
730
+ });
731
+ h += '</div>';
732
+ }
733
+
734
+ if (!showUpload && !uploaded.length) {
735
+ h = `<p class="text-muted small">${_esc(ed.placeholder || 'No files')}</p>`;
736
+ }
737
+
738
+ el.innerHTML = h;
739
+
740
+ if (!showUpload) {
741
+ el._fileUpload = { getFiles: () => [], clear: () => {} };
742
+ return;
743
+ }
744
+
745
+ const dz = document.getElementById(uid + '-dz');
746
+ const fi = document.getElementById(uid + '-fi');
747
+ const stagedEl = document.getElementById(uid + '-staged');
748
+ if (!dz) return;
749
+
750
+ function addFiles(fileList) {
751
+ for (const f of fileList) {
752
+ const ext = '.' + f.name.split('.').pop().toLowerCase();
753
+ if (!acceptSet.has(ext)) continue;
754
+ if (!stagedFiles.find(s => s.name === f.name)) stagedFiles.push(f);
755
+ }
756
+ renderStaged();
757
+ cfg.onPatchState(node.id, { _stagedFiles: stagedFiles.map(f => ({ name: f.name, size: f.size })) });
758
+ }
759
+
760
+ function renderStaged() {
761
+ if (!stagedFiles.length) { stagedEl.innerHTML = ''; return; }
762
+ let sh = '';
763
+ stagedFiles.forEach((f, i) => {
764
+ sh += '<div class="lc-staged-file">';
765
+ sh += '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>';
766
+ sh += `<span class="small flex-grow-1 text-truncate">${_esc(f.name)}</span>`;
767
+ sh += `<button class="btn btn-sm btn-link text-danger p-0 lc-rm-staged" data-idx="${i}">&times;</button>`;
768
+ sh += '</div>';
769
+ });
770
+ stagedEl.innerHTML = sh;
771
+ stagedEl.querySelectorAll('.lc-rm-staged').forEach(btn => {
772
+ btn.addEventListener('click', () => {
773
+ stagedFiles.splice(parseInt(btn.dataset.idx), 1);
774
+ el._stagedFiles = stagedFiles;
775
+ renderStaged();
776
+ }, { signal });
777
+ });
778
+ }
779
+
780
+ dz.addEventListener('click', () => fi.click(), { signal });
781
+ dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('lc-drag-over'); }, { signal });
782
+ dz.addEventListener('dragleave', () => dz.classList.remove('lc-drag-over'), { signal });
783
+ dz.addEventListener('drop', e => { e.preventDefault(); dz.classList.remove('lc-drag-over'); addFiles(e.dataTransfer.files); }, { signal });
784
+ fi.addEventListener('change', e => { addFiles(e.target.files); e.target.value = ''; }, { signal });
785
+
786
+ renderStaged();
787
+
788
+ el._fileUpload = {
789
+ getFiles: () => stagedFiles,
790
+ clear: () => { stagedFiles = []; el._stagedFiles = []; renderStaged(); },
791
+ disable: () => { dz.classList.add('lc-disabled'); fi.disabled = true; },
792
+ enable: () => { dz.classList.remove('lc-disabled'); fi.disabled = false; },
793
+ };
794
+ }
795
+
796
+ // ---- chat (element kind) ----
797
+
798
+ function _renderChatEl(data, el, elemDef, node) {
799
+ const cleanup = _getCleanup(node.id);
800
+ const signal = cleanup.ac.signal;
801
+ const ed = elemDef.data || {};
802
+ const messages = Array.isArray(data) ? data : [];
803
+ const placeholder = ed.placeholder || 'Type a message...';
804
+ const canAttach = ed.fileAttach === true;
805
+ const accept = ed.fileAccept || ['.txt','.csv','.md','.json','.html','.xml','.pdf','.xlsx','.docx','.pptx','.png','.jpg','.jpeg'];
806
+ const uid = 'lc-ch-' + (elemDef.id || Math.random().toString(36).slice(2, 8));
807
+
808
+ let h = '<div class="lc-chat-el">';
809
+ h += `<div class="lc-chat-body" id="${uid}-body"></div>`;
810
+ h += '<div class="lc-chat-input-bar">';
811
+ if (canAttach) {
812
+ h += `<input type="file" id="${uid}-fi" class="d-none" multiple accept="${accept.join(',')}">`;
813
+ h += `<button class="btn btn-sm btn-outline-secondary" id="${uid}-attach" title="Attach files" type="button">`;
814
+ h += '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>';
815
+ h += '</button>';
816
+ }
817
+ h += `<input type="text" class="form-control form-control-sm flex-grow-1" id="${uid}-input" placeholder="${_esc(placeholder)}">`;
818
+ h += `<button class="btn btn-sm btn-outline-primary" id="${uid}-send" type="button">`;
819
+ h += '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>';
820
+ h += '</button></div>';
821
+ if (canAttach) h += `<div id="${uid}-staged" class="mt-1"></div>`;
822
+ h += '</div>';
823
+
824
+ el.innerHTML = h;
825
+
826
+ const body = document.getElementById(uid + '-body');
827
+ const input = document.getElementById(uid + '-input');
828
+ const sendBtn = document.getElementById(uid + '-send');
829
+ const attachBtn = canAttach ? document.getElementById(uid + '-attach') : null;
830
+ const fileInput = canAttach ? document.getElementById(uid + '-fi') : null;
831
+ const stagedEl = canAttach ? document.getElementById(uid + '-staged') : null;
832
+
833
+ let stagedFiles = [];
834
+
835
+ function appendMsg(msg) {
836
+ const bub = document.createElement('div');
837
+ const roleClass = msg.role === 'user' ? 'lc-chat-bubble-user'
838
+ : msg.role === 'assistant' ? 'lc-chat-bubble-assistant'
839
+ : 'lc-chat-bubble-system';
840
+ bub.className = 'lc-chat-bubble ' + roleClass;
841
+ if (msg.role === 'assistant') {
842
+ bub.innerHTML = _renderMd(msg.text || '');
843
+ } else {
844
+ bub.textContent = msg.text || '';
845
+ }
846
+ if (msg.files && msg.files.length) {
847
+ const fDiv = document.createElement('div');
848
+ fDiv.className = 'small mt-1';
849
+ msg.files.forEach(f => {
850
+ const name = typeof f === 'string' ? f : f.name;
851
+ fDiv.innerHTML += '\uD83D\uDCCE ' + _esc(name) + '<br>';
852
+ });
853
+ bub.appendChild(fDiv);
854
+ }
855
+ body.appendChild(bub);
856
+ }
857
+
858
+ messages.forEach(appendMsg);
859
+ body.scrollTop = body.scrollHeight;
860
+
861
+ function renderStaged() {
862
+ if (!stagedEl) return;
863
+ if (!stagedFiles.length) { stagedEl.innerHTML = ''; return; }
864
+ stagedEl.innerHTML = stagedFiles.map((f, i) =>
865
+ `<div class="d-flex align-items-center gap-1 small"><span>\uD83D\uDCCE ${_esc(f.name)}</span><button class="btn btn-sm btn-link text-danger p-0 lc-rm-cs" data-idx="${i}">&times;</button></div>`
866
+ ).join('');
867
+ stagedEl.querySelectorAll('.lc-rm-cs').forEach(btn => {
868
+ btn.addEventListener('click', () => { stagedFiles.splice(parseInt(btn.dataset.idx), 1); renderStaged(); }, { signal });
869
+ });
870
+ }
871
+
872
+ if (attachBtn && fileInput) {
873
+ const acceptS = new Set(accept.map(x => x.toLowerCase()));
874
+ attachBtn.addEventListener('click', () => fileInput.click(), { signal });
875
+ fileInput.addEventListener('change', e => {
876
+ for (const f of e.target.files) {
877
+ const ext = '.' + f.name.split('.').pop().toLowerCase();
878
+ if (acceptS.has(ext) && !stagedFiles.find(s => s.name === f.name)) stagedFiles.push(f);
879
+ }
880
+ e.target.value = '';
881
+ renderStaged();
882
+ }, { signal });
883
+ }
884
+
885
+ function doSend() {
886
+ const text = input.value.trim();
887
+ if (!text && !stagedFiles.length) return;
888
+ const msg = { role: 'user', text: text || '' };
889
+ if (stagedFiles.length) msg.files = stagedFiles.map(f => ({ name: f.name, size: f.size }));
890
+ appendMsg(msg);
891
+ body.scrollTop = body.scrollHeight;
892
+ input.value = '';
893
+ const filesToSend = stagedFiles.slice();
894
+ stagedFiles = [];
895
+ renderStaged();
896
+ cfg.onAction(node.id, 'chat-send', { text: msg.text, files: filesToSend, elemId: elemDef.id });
897
+ }
898
+
899
+ sendBtn.addEventListener('click', doSend, { signal });
900
+ input.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doSend(); } }, { signal });
901
+
902
+ el._chat = {
903
+ appendMessage: (role, text, files) => { appendMsg({ role, text, files }); body.scrollTop = body.scrollHeight; },
904
+ showProcessing: (text) => {
905
+ let ind = body.querySelector('.lc-chat-processing');
906
+ if (!ind) {
907
+ ind = document.createElement('div');
908
+ ind.className = 'lc-chat-processing';
909
+ ind.innerHTML = '<span class="spinner-border spinner-border-sm"></span><span class="small">Processing...</span>';
910
+ body.appendChild(ind);
911
+ }
912
+ if (text) ind.querySelector('.small').textContent = text;
913
+ body.scrollTop = body.scrollHeight;
914
+ },
915
+ removeProcessing: () => { const ind = body.querySelector('.lc-chat-processing'); if (ind) ind.remove(); },
916
+ disable: () => { input.disabled = true; sendBtn.disabled = true; if (attachBtn) attachBtn.disabled = true; },
917
+ enable: () => { input.disabled = false; sendBtn.disabled = false; if (attachBtn) attachBtn.disabled = false; },
918
+ };
919
+ }
920
+
921
+ // ---- actions ----
922
+
923
+ function _renderActions(data, el, elemDef, node) {
924
+ const cleanup = _getCleanup(node.id);
925
+ const signal = cleanup.ac.signal;
926
+ const ed = elemDef.data || {};
927
+ const buttons = ed.buttons || (Array.isArray(data) ? data : []);
928
+ if (!buttons.length) { el.innerHTML = ''; return; }
929
+
930
+ let h = '<div class="d-flex gap-2 flex-wrap">';
931
+ buttons.forEach(btn => {
932
+ const style = btn.style || 'outline-secondary';
933
+ const size = btn.size || 'sm';
934
+ const dis = typeof btn.disabled === 'string' ? _resolveBind(node, btn.disabled) : btn.disabled;
935
+ h += `<button class="btn btn-${_esc(style)} btn-${size}" data-action-id="${_esc(btn.id)}"${dis ? ' disabled' : ''}>`;
936
+ h += _esc(btn.label || btn.id);
937
+ h += '</button>';
938
+ });
939
+ h += '</div>';
940
+ el.innerHTML = h;
941
+
942
+ el.querySelectorAll('[data-action-id]').forEach(btnEl => {
943
+ btnEl.addEventListener('click', () => {
944
+ cfg.onAction(node.id, 'action', { buttonId: btnEl.dataset.actionId, elemId: elemDef.id });
945
+ }, { signal });
946
+ });
947
+
948
+ el._actions = {
949
+ setDisabled: (buttonId, disabled) => {
950
+ const b = el.querySelector(`[data-action-id="${buttonId}"]`);
951
+ if (b) b.disabled = disabled;
952
+ },
953
+ setLabel: (buttonId, label) => {
954
+ const b = el.querySelector(`[data-action-id="${buttonId}"]`);
955
+ if (b) b.textContent = label;
956
+ },
957
+ };
958
+ }
959
+
674
960
  // ---- Register built-in renderers ----
675
961
 
676
962
  _renderers.table = _renderTable;
@@ -687,6 +973,9 @@ var LiveCard = (function () {
687
973
  _renderers.text = _renderText;
688
974
  _renderers.markdown = _renderMarkdown;
689
975
  _renderers.custom = _renderCustom;
976
+ _renderers['file-upload'] = _renderFileUpload;
977
+ _renderers['chat'] = _renderChatEl;
978
+ _renderers.actions = _renderActions;
690
979
 
691
980
  // ===========================================================================
692
981
  // _renderElements — render all view.elements for a card node
@@ -696,6 +985,8 @@ var LiveCard = (function () {
696
985
  const view = node.view;
697
986
  if (!view || !Array.isArray(view.elements)) { containerEl.innerHTML = ''; return; }
698
987
 
988
+ if (_nodeEls[node.id]) _nodeEls[node.id].elements = {};
989
+
699
990
  const container = document.createElement('div');
700
991
  container.className = 'row g-2';
701
992
 
@@ -729,6 +1020,8 @@ var LiveCard = (function () {
729
1020
  inner.innerHTML = `<div class="text-danger small">Render error: ${_esc(e.message)}</div>`;
730
1021
  }
731
1022
 
1023
+ if (elemDef.id && _nodeEls[node.id]) _nodeEls[node.id].elements[elemDef.id] = inner;
1024
+
732
1025
  container.appendChild(col);
733
1026
  });
734
1027
 
@@ -929,6 +1222,15 @@ var LiveCard = (function () {
929
1222
  chatEl.scrollTop = chatEl.scrollHeight;
930
1223
  }
931
1224
 
1225
+ // ===========================================================================
1226
+ // Element access
1227
+ // ===========================================================================
1228
+
1229
+ function getElement(nodeId, elemId) {
1230
+ const info = _nodeEls[nodeId];
1231
+ return (info && info.elements && info.elements[elemId]) || null;
1232
+ }
1233
+
932
1234
  // ===========================================================================
933
1235
  // Return engine
934
1236
  // ===========================================================================
@@ -941,6 +1243,7 @@ var LiveCard = (function () {
941
1243
  notify,
942
1244
  subscribe,
943
1245
  appendChatMessage,
1246
+ getElement,
944
1247
  registerRenderer(name, fn) { _renderers[name] = fn; },
945
1248
  renderers: _renderers,
946
1249
  };