json-object-editor 0.10.624 → 0.10.632

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/js/joe-ai.js CHANGED
@@ -6,6 +6,27 @@
6
6
  Ai.default_ai = null; // Default AI assistant object
7
7
  // ========== COMPONENTS ==========
8
8
 
9
+ // Simple markdown -> HTML helper used by chat UI components.
10
+ // Uses global `marked` and `DOMPurify` when available, falls back to
11
+ // basic escaping + <br> conversion otherwise.
12
+ function renderMarkdownSafe(text) {
13
+ const raw = text || '';
14
+ if (typeof window !== 'undefined' && window.marked && window.DOMPurify) {
15
+ try {
16
+ const html = window.marked.parse(raw);
17
+ return window.DOMPurify.sanitize(html);
18
+ } catch (e) {
19
+ console.error('[joe-ai] markdown render error', e);
20
+ // fall through to plain escape
21
+ }
22
+ }
23
+ return raw
24
+ .replace(/&/g, '&amp;')
25
+ .replace(/</g, '&lt;')
26
+ .replace(/>/g, '&gt;')
27
+ .replace(/\n/g, '<br>');
28
+ }
29
+
9
30
  class JoeAIChatbox extends HTMLElement {
10
31
  constructor() {
11
32
  super();
@@ -168,7 +189,31 @@
168
189
  this.thread_id = convo.thread_id;
169
190
  this.user = $J.get(convo.user);
170
191
  }
171
-
192
+
193
+ // If there is no assistant reply yet, inject a local greeting based on context.
194
+ // This still shows even if we have a single PLATFORM/system card from context.
195
+ const hasAssistantReply = Array.isArray(this.messages) &&
196
+ this.messages.some(m => m.role === 'assistant');
197
+ if (!hasAssistantReply) {
198
+ try {
199
+ const user = this.user || (convo.user && $J.get(convo.user)) || {};
200
+ const contextId = (convo.context_objects && convo.context_objects[0]) || null;
201
+ const ctxObj = contextId ? $J.get(contextId) : null;
202
+ const uname = user.name || 'there';
203
+ let target = 'this conversation';
204
+ if (ctxObj) {
205
+ const label = ctxObj.name || ctxObj.title || ctxObj.label || ctxObj._id;
206
+ target = `${label} (${ctxObj.itemtype || 'item'})`;
207
+ }
208
+ const greeting = `Hi ${uname}, I’m ready to help you with ${target}. What would you like to explore or change?`;
209
+ this.messages.push({
210
+ role: 'assistant',
211
+ content: greeting,
212
+ created_at: Math.floor(Date.now()/1000)
213
+ });
214
+ } catch(_e){}
215
+ }
216
+
172
217
  return {conversation:convo,messages:this.messages};
173
218
  } catch (err) {
174
219
  console.error("Chatbox load error:", err);
@@ -297,18 +342,27 @@
297
342
  body: JSON.stringify({
298
343
  conversation_id: this.conversation_id,
299
344
  content: message,
300
- assistant_id: this.selected_assistant_id||Ai.default_ai?Ai.default_ai.value:null
345
+ assistant_id: this.selected_assistant_id || (Ai.default_ai ? Ai.default_ai.value : null)
301
346
  })
302
347
  }).then(res => res.json());
303
348
 
304
- if (response.success && response.runObj) {
305
- this.currentRunId = response.runObj.id; // Store the run ID for polling
306
- await this.loadConversation(); // reload messages
349
+ if (response.success && (response.run_id || (response.runObj && response.runObj.id))) {
350
+ // Use IDs from server so we don't depend on cache or stale conversation data
351
+ const threadId = response.thread_id || this.conversation.thread_id;
352
+ this.conversation.thread_id = threadId;
353
+ this.currentRunId = response.run_id || (response.runObj && response.runObj.id);
354
+
355
+ // Reload conversation (name, info, etc.), but preserve the known thread_id
356
+ await this.loadConversation();
357
+ if (!this.conversation.thread_id && threadId) {
358
+ this.conversation.thread_id = threadId;
359
+ }
360
+
307
361
  this.UI.content.update(); // update messages
308
362
  this.startPolling(); // 🌸 start watching for assistant reply!
309
363
  input.value = '';
310
364
  } else {
311
- alert('Failed to send message.');
365
+ alert((response.error||'Failed to send message.')+' - '+response.message);
312
366
  }
313
367
  } catch (err) {
314
368
  console.error('Send message error:', err);
@@ -325,27 +379,56 @@
325
379
 
326
380
  // Insert thinking message
327
381
  this.showThinkingMessage();
328
-
382
+
383
+ // Baseline: timestamp of the latest assistant message we have so far
384
+ const lastAssistantTs = (this.messages || [])
385
+ .filter(m => m && m.role === 'assistant' && m.created_at)
386
+ .reduce((max, m) => Math.max(max, m.created_at), 0);
387
+
388
+ let attempts = 0;
329
389
  this.pollingInterval = setInterval(async () => {
330
- const runRes = await fetch(`/API/plugin/chatgpt-assistants/getRunStatus?thread_id=${this.conversation.thread_id}&run_id=${this.currentRunId}`);
331
- const run = await runRes.json();
332
-
333
- if (run.status === 'completed') {
334
- const resThread = await fetch(`/API/plugin/chatgpt-assistants/getThreadMessages?thread_id=${this.conversation.thread_id}`);
335
- //const activeAssistant = this.conversation.assistants.find(a => a.openai_id === run.assistant_id);
390
+ attempts++;
391
+ try {
392
+ const resThread = await fetch(`/API/plugin/chatgpt-assistants/getThreadMessages?thread_id=${this.conversation.thread_id}&polling=true`);
336
393
  const threadMessages = await resThread.json();
337
-
394
+
338
395
  if (threadMessages?.messages) {
339
- this.UI.content.update(threadMessages.messages);
340
- //this.messages = threadMessages.messages;
341
- //this.render();
396
+ const msgs = threadMessages.messages;
397
+ const latestAssistantTs = msgs
398
+ .filter(m => m && m.role === 'assistant' && m.created_at)
399
+ .reduce((max, m) => Math.max(max, m.created_at), 0);
400
+
401
+ // When we see a newer assistant message than we had before, treat it as the reply.
402
+ if (latestAssistantTs && latestAssistantTs > lastAssistantTs) {
403
+ this.messages = msgs;
404
+ this.UI.content.update(msgs);
405
+
406
+ clearInterval(this.pollingInterval);
407
+ this.pollingInterval = null;
408
+ this.hideThinkingMessage();
409
+ this.UI.textarea.disabled = false;
410
+ this.UI.sendButton.disabled = false;
411
+ return;
412
+ }
342
413
  }
343
-
414
+
415
+ if (attempts > 60) { // ~2 minutes
416
+ console.warn('Thread polling timeout for assistant reply');
417
+ clearInterval(this.pollingInterval);
418
+ this.pollingInterval = null;
419
+ this.hideThinkingMessage();
420
+ this.UI.textarea.disabled = false;
421
+ this.UI.sendButton.disabled = false;
422
+ alert('Timed out waiting for assistant response.');
423
+ }
424
+ } catch (err) {
425
+ console.error('Polling error (thread messages):', err);
344
426
  clearInterval(this.pollingInterval);
345
427
  this.pollingInterval = null;
346
428
  this.hideThinkingMessage();
347
429
  this.UI.textarea.disabled = false;
348
430
  this.UI.sendButton.disabled = false;
431
+ alert('Error while checking assistant response.');
349
432
  }
350
433
  }, 2000);
351
434
  }
@@ -417,17 +500,33 @@
417
500
  this.conversation_id = this.getAttribute('conversation_id') || null;
418
501
  this.assistant_id = this.getAttribute('assistant_id') || null;
419
502
  this.ai_assistant_id = this.getAttribute('ai_assistant_id') || null;
503
+ this.user_id = this.getAttribute('user_id') || null;
420
504
  this.model = this.getAttribute('model') || null;
505
+ this.assistant_color = this.getAttribute('assistant_color') || null;
506
+ this.user_color = this.getAttribute('user_color') || null;
507
+ // Optional persistence key so widgets inside dynamic layouts (like capp cards)
508
+ // can restore their conversation after re-render or resize.
509
+ this.persist_key =
510
+ this.getAttribute('persist_key') ||
511
+ (this.getAttribute('source') ? ('joe-ai-widget:' + this.getAttribute('source')) : null);
421
512
  this.messages = [];
422
513
  this._ui = {};
423
514
  }
424
515
 
425
516
  connectedCallback() {
426
517
  this.renderShell();
518
+ // If we don't have an explicit conversation_id but a persisted one exists,
519
+ // restore it before deciding what to load.
520
+ this.restoreFromStorageIfNeeded();
521
+ // Lazy conversation creation:
522
+ // - If a conversation_id is provided, load its history.
523
+ // - Otherwise, do NOT create a conversation until the user actually
524
+ // sends a message. `sendMessage` will call startConversation() on
525
+ // demand when needed.
427
526
  if (this.conversation_id) {
428
527
  this.loadHistory();
429
528
  } else {
430
- this.startConversation();
529
+ this.setStatus('online');
431
530
  }
432
531
  }
433
532
 
@@ -435,13 +534,44 @@
435
534
  return this.endpoint || '';
436
535
  }
437
536
 
537
+ persistState() {
538
+ if (!this.persist_key || typeof window === 'undefined' || !window.localStorage) return;
539
+ try {
540
+ const payload = {
541
+ conversation_id: this.conversation_id,
542
+ assistant_id: this.assistant_id,
543
+ model: this.model,
544
+ assistant_color: this.assistant_color
545
+ };
546
+ window.localStorage.setItem(this.persist_key, JSON.stringify(payload));
547
+ } catch (_e) { /* ignore storage errors */ }
548
+ }
549
+
550
+ restoreFromStorageIfNeeded() {
551
+ if (this.conversation_id || !this.persist_key || typeof window === 'undefined' || !window.localStorage) return;
552
+ try {
553
+ const raw = window.localStorage.getItem(this.persist_key);
554
+ if (!raw) return;
555
+ const data = JSON.parse(raw);
556
+ if (!data || !data.conversation_id) return;
557
+ this.conversation_id = data.conversation_id;
558
+ this.setAttribute('conversation_id', this.conversation_id);
559
+ this.assistant_id = data.assistant_id || this.assistant_id;
560
+ this.model = data.model || this.model;
561
+ this.assistant_color = data.assistant_color || this.assistant_color;
562
+ } catch (_e) { /* ignore */ }
563
+ }
564
+
438
565
  renderShell() {
439
566
  const style = document.createElement('style');
440
567
  style.textContent = `
441
568
  :host {
442
569
  font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
443
570
  display: block;
444
- max-width: 420px;
571
+ /* Allow the widget to grow/shrink with its container (cards, sidebars, etc.) */
572
+ width: 100%;
573
+ height: 100%;
574
+ max-width: none;
445
575
  border: 1px solid #ddd;
446
576
  border-radius: 8px;
447
577
  box-shadow: 0 2px 6px rgba(0,0,0,0.08);
@@ -471,7 +601,9 @@
471
601
  }
472
602
  .messages {
473
603
  padding: 10px;
474
- height: 260px;
604
+ /* Fill remaining space between header and footer */
605
+ flex: 1 1 auto;
606
+ min-height: 0;
475
607
  overflow-y: auto;
476
608
  background: #f5f7fa;
477
609
  font-size: 13px;
@@ -488,17 +620,24 @@
488
620
  .bubble {
489
621
  display: inline-block;
490
622
  padding: 6px 8px;
491
- border-radius: 10px;
623
+ border-radius: 5px;
492
624
  line-height: 1.4;
625
+ max-width: 100%;
626
+ overflow-x: auto;
493
627
  }
494
628
  .user .bubble {
495
- background: #2563eb;
629
+ background: var(--joe-ai-user-bg, #2563eb);
496
630
  color: #fff;
497
631
  }
498
632
  .assistant .bubble {
499
- background: #e5e7eb;
633
+ background: var(--joe-ai-assistant-bg, #e5e7eb);
500
634
  color: #111827;
501
635
  }
636
+ .msg.assistant.tools-used .bubble {
637
+ background: #fef3c7;
638
+ color: #92400e;
639
+ font-size: 11px;
640
+ }
502
641
  .footer {
503
642
  border-top: 1px solid #e5e7eb;
504
643
  padding: 6px;
@@ -562,22 +701,72 @@
562
701
  this.sendMessage();
563
702
  }
564
703
  });
704
+ this.applyThemeColors();
705
+ }
706
+
707
+ applyThemeColors() {
708
+ if (!this.shadowRoot || !this.shadowRoot.host) return;
709
+ const host = this.shadowRoot.host;
710
+ if (this.assistant_color) {
711
+ host.style.setProperty('--joe-ai-assistant-bg', this.assistant_color);
712
+ }
713
+ if (this.user_color) {
714
+ host.style.setProperty('--joe-ai-user-bg', this.user_color);
715
+ }
565
716
  }
566
717
 
567
718
  setStatus(text) {
568
- if (this._ui.status) this._ui.status.textContent = text;
719
+ if (!this._ui.status) return;
720
+ const cid = this.conversation_id || '';
721
+ // Show conversation id when available, e.g. "cuid123 - online"
722
+ this._ui.status.textContent = cid ? (cid + ' - ' + text) : text;
569
723
  }
570
724
 
571
725
  renderMessages() {
572
726
  if (!this._ui.messages) return;
573
727
  this._ui.messages.innerHTML = (this.messages || []).map(m => {
574
728
  const role = m.role || 'assistant';
575
- const safe = (m.content || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
576
- return `<div class="msg ${role}"><div class="bubble">${safe}</div></div>`;
729
+ const extra = m.meta === 'tools_used' ? ' tools-used' : '';
730
+ const html = renderMarkdownSafe(m.content || '');
731
+ return `<div class="msg ${role}${extra}"><div class="bubble">${html}</div></div>`;
577
732
  }).join('');
578
733
  this._ui.messages.scrollTop = this._ui.messages.scrollHeight;
579
734
  }
580
735
 
736
+ // Resolve the logical user for this widget instance.
737
+ // Priority:
738
+ // 1) Explicit user_id attribute on the element (fetch /API/item/user/_id/:id)
739
+ // 2) Page-level window._aiWidgetUser (used by ai-widget-test.html)
740
+ // 3) null (caller can still force a user_color theme)
741
+ async resolveUser() {
742
+ if (this._resolvedUser) {
743
+ return this._resolvedUser;
744
+ }
745
+ // 1) If the hosting page passed a user_id attribute, look up that user via JOE.
746
+ if (this.user_id) {
747
+ try {
748
+ const res = await fetch(
749
+ this.apiBase + '/API/item/user/_id/' + encodeURIComponent(this.user_id),
750
+ { credentials: 'include' }
751
+ );
752
+ const data = await res.json();
753
+ const u = (data && data.item) || null;
754
+ if (u && u._id) {
755
+ this._resolvedUser = u;
756
+ return u;
757
+ }
758
+ } catch (e) {
759
+ console.error('JoeAIWidget.resolveUser: failed to load user by id', this.user_id, e);
760
+ }
761
+ }
762
+ // 2) Fall back to a page-global user (ai-widget-test.html populates this).
763
+ if (typeof window !== 'undefined' && window._aiWidgetUser) {
764
+ this._resolvedUser = window._aiWidgetUser;
765
+ return this._resolvedUser;
766
+ }
767
+ return null;
768
+ }
769
+
581
770
  async startConversation() {
582
771
  try {
583
772
  this.setStatus('connecting…');
@@ -586,27 +775,48 @@
586
775
  ai_assistant_id: this.getAttribute('ai_assistant_id') || undefined,
587
776
  source: this.getAttribute('source') || 'widget'
588
777
  };
589
- const resp = await fetch(this.apiBase + '/API/plugin/chatgpt/widgetStart', {
778
+ // Resolve the effective user for this widget and pass id/name/color
779
+ // explicitly to the server. This works for:
780
+ // - ai-widget-test.html (which sets window._aiWidgetUser)
781
+ // - JOE pages or external sites that pass a user_id attribute
782
+ try {
783
+ const globalUser = await this.resolveUser();
784
+ if (globalUser) {
785
+ payload.user_id = globalUser._id;
786
+ payload.user_name = globalUser.fullname || globalUser.name;
787
+ if (globalUser.color) { payload.user_color = globalUser.color; }
788
+ } else if (this.user_color) {
789
+ payload.user_color = this.user_color;
790
+ }
791
+ } catch (_e) { /* ignore */ }
792
+ const resp = await fetch(this.apiBase + '/API/plugin/chatgpt/widgetStart', {
590
793
  method: 'POST',
591
794
  headers: { 'Content-Type': 'application/json' },
592
795
  body: JSON.stringify(payload)
593
796
  }).then(r => r.json());
594
797
 
595
798
  if (!resp || resp.success !== true) {
596
- const msg = (resp && resp.error) || 'Failed to start conversation';
597
- this.setStatus('error: ' + msg);
598
- this.messages.push({ role:'assistant', content:'[Error starting conversation: '+msg+']', created_at:new Date().toISOString() });
599
- this.renderMessages();
600
- console.error('widgetStart error', resp);
799
+ const msg = (resp && (resp.error || resp.message)) || 'Failed to start conversation';
800
+ this.setStatus('error: ' + msg);
801
+ this.messages.push({
802
+ role:'assistant',
803
+ content:'[Error starting conversation: '+msg+']',
804
+ created_at:new Date().toISOString()
805
+ });
806
+ this.renderMessages();
807
+ console.error('widgetStart error', { payload, response: resp });
601
808
  return;
602
809
  }
603
810
  this.conversation_id = resp.conversation_id;
604
811
  this.setAttribute('conversation_id', this.conversation_id);
605
812
  this.assistant_id = resp.assistant_id || this.assistant_id;
606
813
  this.model = resp.model || this.model;
814
+ this.assistant_color = resp.assistant_color || this.assistant_color;
815
+ this.applyThemeColors();
607
816
  this.messages = [];
608
817
  this.renderMessages();
609
818
  this.setStatus('online');
819
+ this.persistState();
610
820
  } catch (e) {
611
821
  console.error('widgetStart exception', e);
612
822
  this.setStatus('error');
@@ -616,17 +826,23 @@
616
826
  async loadHistory() {
617
827
  try {
618
828
  this.setStatus('loading…');
619
- const resp = await fetch(this.apiBase + '/API/plugin/chatgpt/widgetHistory?conversation_id=' + encodeURIComponent(this.conversation_id))
620
- .then(r => r.json());
829
+ const resp = await fetch(
830
+ this.apiBase + '/API/plugin/chatgpt/widgetHistory?conversation_id=' +
831
+ encodeURIComponent(this.conversation_id)
832
+ ).then(r => r.json());
621
833
  if (!resp || resp.success !== true) {
834
+ console.warn('widgetHistory non-success response', resp);
622
835
  this.setStatus('online');
623
836
  return;
624
837
  }
625
838
  this.assistant_id = resp.assistant_id || this.assistant_id;
626
839
  this.model = resp.model || this.model;
840
+ this.assistant_color = resp.assistant_color || this.assistant_color;
841
+ this.applyThemeColors();
627
842
  this.messages = resp.messages || [];
628
843
  this.renderMessages();
629
844
  this.setStatus('online');
845
+ this.persistState();
630
846
  } catch (e) {
631
847
  console.error('widgetHistory exception', e);
632
848
  this.setStatus('online');
@@ -658,6 +874,16 @@
658
874
  assistant_id: this.assistant_id || undefined,
659
875
  model: this.model || undefined
660
876
  };
877
+ try {
878
+ const globalUser = await this.resolveUser();
879
+ if (globalUser) {
880
+ payload.user_id = globalUser._id;
881
+ payload.user_name = globalUser.fullname || globalUser.name;
882
+ if (globalUser.color) { payload.user_color = globalUser.color; }
883
+ } else if (this.user_color) {
884
+ payload.user_color = this.user_color;
885
+ }
886
+ } catch (_e) { /* ignore */ }
661
887
  const resp = await fetch(this.apiBase + '/API/plugin/chatgpt/widgetMessage', {
662
888
  method: 'POST',
663
889
  headers: { 'Content-Type': 'application/json' },
@@ -665,17 +891,20 @@
665
891
  }).then(r => r.json());
666
892
 
667
893
  if (!resp || resp.success !== true) {
668
- const msg = (resp && resp.error) || 'Failed to send message';
669
- console.error('widgetMessage error', resp);
894
+ const msg = (resp && (resp.error || resp.message)) || 'Failed to send message';
895
+ console.error('widgetMessage error', { payload, response: resp });
670
896
  this.messages.push({ role:'assistant', content:'[Error: '+msg+']', created_at:new Date().toISOString() });
671
897
  this.renderMessages();
672
898
  this.setStatus('error: ' + msg);
673
899
  } else {
674
900
  this.assistant_id = resp.assistant_id || this.assistant_id;
675
901
  this.model = resp.model || this.model;
902
+ this.assistant_color = resp.assistant_color || this.assistant_color;
903
+ this.applyThemeColors();
676
904
  this.messages = resp.messages || this.messages;
677
905
  this.renderMessages();
678
906
  this.setStatus('online');
907
+ this.persistState();
679
908
  }
680
909
  } catch (e) {
681
910
  console.error('widgetMessage exception', e);
@@ -690,6 +919,447 @@
690
919
  customElements.define('joe-ai-widget', JoeAIWidget);
691
920
  }
692
921
 
922
+ // ---------- Assistant picker: small selector component for joe-ai-widget ----------
923
+ class JoeAIAssistantPicker extends HTMLElement {
924
+ constructor() {
925
+ super();
926
+ this.attachShadow({ mode: 'open' });
927
+ this._ui = {};
928
+ this._assistants = [];
929
+ this._defaultId = null;
930
+ }
931
+
932
+ connectedCallback() {
933
+ this.renderShell();
934
+ this.init();
935
+ }
936
+
937
+ get widget() {
938
+ const targetId = this.getAttribute('for_widget');
939
+ if (targetId) {
940
+ return document.getElementById(targetId);
941
+ }
942
+ // Fallback: nearest joe-ai-widget in the same card/container
943
+ return this.closest('capp-card,div')?.querySelector('joe-ai-widget') || null;
944
+ }
945
+
946
+ renderShell() {
947
+ const style = document.createElement('style');
948
+ style.textContent = `
949
+ :host {
950
+ display: block;
951
+ margin-bottom: 6px;
952
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
953
+ font-size: 12px;
954
+ }
955
+ .row {
956
+ display: flex;
957
+ align-items: center;
958
+ justify-content: space-between;
959
+ gap: 6px;
960
+ }
961
+ label {
962
+ color: #374151;
963
+ }
964
+ select {
965
+ margin-left: 4px;
966
+ padding: 2px 4px;
967
+ font-size: 12px;
968
+ }
969
+ .hint {
970
+ font-size: 11px;
971
+ color: #6b7280;
972
+ margin-left: 4px;
973
+ }
974
+ `;
975
+ const wrapper = document.createElement('div');
976
+ wrapper.className = 'row';
977
+ wrapper.innerHTML = `
978
+ <div>
979
+ <label>Assistant:</label>
980
+ <select id="assistant-select"></select>
981
+ <span id="assistant-hint" class="hint"></span>
982
+ </div>
983
+ <div>
984
+ <button id="new-convo" style="font-size:11px;padding:2px 8px;">New conversation</button>
985
+ </div>
986
+ `;
987
+ this.shadowRoot.innerHTML = '';
988
+ this.shadowRoot.appendChild(style);
989
+ this.shadowRoot.appendChild(wrapper);
990
+ this._ui.select = this.shadowRoot.getElementById('assistant-select');
991
+ this._ui.hint = this.shadowRoot.getElementById('assistant-hint');
992
+ this._ui.newConvo = this.shadowRoot.getElementById('new-convo');
993
+ }
994
+
995
+ async init() {
996
+ const select = this._ui.select;
997
+ const hint = this._ui.hint;
998
+ const widget = this.widget;
999
+ if (!select || !widget) return;
1000
+
1001
+ try {
1002
+ const [assistantsResp, settingsResp] = await Promise.all([
1003
+ fetch('/API/item/ai_assistant', { credentials: 'include' }).then(r => r.json()),
1004
+ fetch('/API/item/setting', { credentials: 'include' }).then(r => r.json())
1005
+ ]);
1006
+ this._assistants = Array.isArray(assistantsResp.item) ? assistantsResp.item : [];
1007
+ const settings = Array.isArray(settingsResp.item) ? settingsResp.item : [];
1008
+ const def = settings.find(s => s && s.name === 'DEFAULT_AI_ASSISTANT');
1009
+ this._defaultId = def && def.value;
1010
+ } catch (e) {
1011
+ console.error('[joe-ai] AssistantPicker init error', e);
1012
+ this._assistants = [];
1013
+ }
1014
+
1015
+ select.innerHTML = '';
1016
+ const noneOption = document.createElement('option');
1017
+ noneOption.value = '';
1018
+ noneOption.textContent = 'None (model only)';
1019
+ select.appendChild(noneOption);
1020
+
1021
+ this._assistants.forEach(a => {
1022
+ const opt = document.createElement('option');
1023
+ opt.value = a._id;
1024
+ opt.textContent = a.name || a.title || a._id;
1025
+ if (a._id === this._defaultId) {
1026
+ opt.textContent += ' (default)';
1027
+ }
1028
+ select.appendChild(opt);
1029
+ });
1030
+
1031
+ let initialId = '';
1032
+ if (widget.getAttribute('ai_assistant_id')) {
1033
+ initialId = widget.getAttribute('ai_assistant_id');
1034
+ hint && (hint.textContent = 'Using assistant from widget attribute.');
1035
+ } else if (this._defaultId && this._assistants.some(a => a && a._id === this._defaultId)) {
1036
+ initialId = this._defaultId;
1037
+ hint && (hint.textContent = 'Using DEFAULT_AI_ASSISTANT.');
1038
+ } else {
1039
+ hint && (hint.textContent = this._assistants.length
1040
+ ? 'No default; using model only.'
1041
+ : 'No assistants defined; using model only.');
1042
+ }
1043
+ select.value = initialId;
1044
+ this.applyAssistantToWidget(widget, initialId);
1045
+
1046
+ select.addEventListener('change', () => {
1047
+ this.applyAssistantToWidget(widget, select.value);
1048
+ });
1049
+
1050
+ if (this._ui.newConvo) {
1051
+ this._ui.newConvo.addEventListener('click', () => {
1052
+ this.startNewConversation(widget);
1053
+ });
1054
+ }
1055
+ }
1056
+
1057
+ startNewConversation(widget) {
1058
+ try {
1059
+ if (!widget) return;
1060
+ // Clear persisted state and conversation id so a fresh widgetStart will happen
1061
+ if (widget.persist_key && typeof window !== 'undefined' && window.localStorage) {
1062
+ try { window.localStorage.removeItem(widget.persist_key); } catch (_e) { /* ignore */ }
1063
+ }
1064
+ widget.removeAttribute('conversation_id');
1065
+ widget.conversation_id = null;
1066
+ if (Array.isArray(widget.messages)) {
1067
+ widget.messages = [];
1068
+ }
1069
+ if (typeof widget.renderMessages === 'function') {
1070
+ widget.renderMessages();
1071
+ }
1072
+ if (typeof widget.setStatus === 'function') {
1073
+ widget.setStatus('online');
1074
+ }
1075
+ } catch (e) {
1076
+ console.error('[joe-ai] error starting new conversation from picker', e);
1077
+ }
1078
+ }
1079
+
1080
+ applyAssistantToWidget(widget, assistantId) {
1081
+ try {
1082
+ if (!widget) return;
1083
+ const val = assistantId || '';
1084
+ if (val) {
1085
+ widget.setAttribute('ai_assistant_id', val);
1086
+ } else {
1087
+ widget.removeAttribute('ai_assistant_id');
1088
+ }
1089
+ // Reset conversation and clear persisted state so a new chat starts
1090
+ if (widget.persist_key && typeof window !== 'undefined' && window.localStorage) {
1091
+ try { window.localStorage.removeItem(widget.persist_key); } catch (_e) { /* ignore */ }
1092
+ }
1093
+ widget.removeAttribute('conversation_id');
1094
+ widget.conversation_id = null;
1095
+ if (typeof widget.setStatus === 'function') {
1096
+ widget.setStatus('online');
1097
+ }
1098
+ } catch (e) {
1099
+ console.error('[joe-ai] error applying assistant selection', e);
1100
+ }
1101
+ }
1102
+ }
1103
+
1104
+ if (!customElements.get('joe-ai-assistant-picker')) {
1105
+ customElements.define('joe-ai-assistant-picker', JoeAIAssistantPicker);
1106
+ }
1107
+
1108
+ // ---------- Conversation list: clickable ai_widget_conversation list ----------
1109
+ class JoeAIConversationList extends HTMLElement {
1110
+ constructor() {
1111
+ super();
1112
+ this.attachShadow({ mode: 'open' });
1113
+ this._ui = {};
1114
+ this._assistants = [];
1115
+ }
1116
+
1117
+ connectedCallback() {
1118
+ this.renderShell();
1119
+ this.refreshConversations();
1120
+ }
1121
+
1122
+ get widget() {
1123
+ const targetId = this.getAttribute('for_widget');
1124
+ if (targetId) {
1125
+ return document.getElementById(targetId);
1126
+ }
1127
+ return this.closest('capp-card,div')?.querySelector('joe-ai-widget') || null;
1128
+ }
1129
+
1130
+ renderShell() {
1131
+ const style = document.createElement('style');
1132
+ style.textContent = `
1133
+ :host {
1134
+ display: block;
1135
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
1136
+ font-size: 12px;
1137
+ }
1138
+ .container {
1139
+ display: flex;
1140
+ flex-direction: column;
1141
+ height: 100%;
1142
+ }
1143
+ .header {
1144
+ display: flex;
1145
+ align-items: center;
1146
+ justify-content: space-between;
1147
+ margin-bottom: 4px;
1148
+ font-weight: 600;
1149
+ }
1150
+ .status {
1151
+ font-size: 11px;
1152
+ margin-bottom: 4px;
1153
+ }
1154
+ .row {
1155
+ margin-bottom: 2px;
1156
+ }
1157
+ .row.chips {
1158
+ display: flex;
1159
+ gap: 4px;
1160
+ flex-wrap: wrap;
1161
+ }
1162
+ .chip {
1163
+ display: inline-block;
1164
+ padding: 1px 6px;
1165
+ border-radius: 999px;
1166
+ font-size: 10px;
1167
+ line-height: 1.4;
1168
+ white-space: nowrap;
1169
+ }
1170
+ .row.link a {
1171
+ font-size: 11px;
1172
+ }
1173
+ ul {
1174
+ list-style: none;
1175
+ padding: 0;
1176
+ margin: 0;
1177
+ flex: 1 1 auto;
1178
+ overflow-y: auto;
1179
+ }
1180
+ li {
1181
+ cursor: pointer;
1182
+ padding: 3px 0;
1183
+ border-bottom: 1px solid #e5e7eb;
1184
+ }
1185
+ li:hover {
1186
+ background: #f9fafb;
1187
+ }
1188
+ `;
1189
+ const wrapper = document.createElement('div');
1190
+ wrapper.className = 'container';
1191
+ wrapper.innerHTML = `
1192
+ <div class="header">
1193
+ <span>Widget Conversations</span>
1194
+ <button id="refresh" style="font-size:11px;padding:2px 6px;">Refresh</button>
1195
+ </div>
1196
+ <div id="status" class="status"></div>
1197
+ <ul id="list"></ul>
1198
+ `;
1199
+ this.shadowRoot.innerHTML = '';
1200
+ this.shadowRoot.appendChild(style);
1201
+ this.shadowRoot.appendChild(wrapper);
1202
+ this._ui.status = this.shadowRoot.getElementById('status');
1203
+ this._ui.list = this.shadowRoot.getElementById('list');
1204
+ this._ui.refresh = this.shadowRoot.getElementById('refresh');
1205
+ this._ui.refresh.addEventListener('click', () => this.refreshConversations());
1206
+ }
1207
+
1208
+ async refreshConversations() {
1209
+ const statusEl = this._ui.status;
1210
+ const listEl = this._ui.list;
1211
+ if (!statusEl || !listEl) return;
1212
+ statusEl.textContent = 'Loading conversations...';
1213
+ listEl.innerHTML = '';
1214
+ try {
1215
+ // Load assistants once for name/color lookup
1216
+ if (!this._assistants || !this._assistants.length) {
1217
+ try {
1218
+ const aRes = await fetch('/API/item/ai_assistant', { credentials: 'include' });
1219
+ const aData = await aRes.json();
1220
+ this._assistants = Array.isArray(aData.item) ? aData.item : [];
1221
+ } catch (_e) {
1222
+ this._assistants = [];
1223
+ }
1224
+ }
1225
+
1226
+ const res = await fetch('/API/item/ai_widget_conversation', { credentials: 'include' });
1227
+ const data = await res.json();
1228
+ let items = Array.isArray(data.item) ? data.item : [];
1229
+ const allItems = items.slice();
1230
+ const sourceFilter = this.getAttribute('source');
1231
+ let filtered = false;
1232
+ if (sourceFilter) {
1233
+ items = items.filter(c => c.source === sourceFilter);
1234
+ filtered = true;
1235
+ }
1236
+ if (!items.length) {
1237
+ if (filtered && allItems.length) {
1238
+ // Fallback: no conversations for this source; show all instead.
1239
+ items = allItems;
1240
+ statusEl.textContent = 'No conversations for source "' + sourceFilter + '". Showing all widget conversations.';
1241
+ } else {
1242
+ statusEl.textContent = 'No widget conversations found.';
1243
+ return;
1244
+ }
1245
+ }
1246
+ items.sort((a, b) => {
1247
+ const da = a.last_message_at || a.joeUpdated || a.created || '';
1248
+ const db = b.last_message_at || b.joeUpdated || b.created || '';
1249
+ return db.localeCompare(da);
1250
+ });
1251
+ statusEl.textContent = 'Click a conversation to resume it.';
1252
+ items.forEach(c => {
1253
+ const li = document.createElement('li');
1254
+ li.dataset.id = c._id;
1255
+
1256
+ const ts = c.last_message_at || c.joeUpdated || c.created || '';
1257
+ const prettyTs = (typeof _joe !== 'undefined' && _joe.Utils && typeof _joe.Utils.prettyPrintDTS === 'function')
1258
+ ? _joe.Utils.prettyPrintDTS(ts)
1259
+ : ts;
1260
+
1261
+ // 1. Conversation title: name or pretty date-time
1262
+ const title = c.name || prettyTs || c._id;
1263
+
1264
+ // Helper to choose readable foreground for a hex bg
1265
+ function textColorForBg(hex) {
1266
+ if (!hex || typeof hex !== 'string' || !/^#?[0-9a-fA-F]{6}$/.test(hex)) return '#000';
1267
+ const h = hex[0] === '#' ? hex.slice(1) : hex;
1268
+ const n = parseInt(h, 16);
1269
+ const r = (n >> 16) & 0xff;
1270
+ const g = (n >> 8) & 0xff;
1271
+ const b = n & 0xff;
1272
+ const luminance = r * 0.299 + g * 0.587 + b * 0.114;
1273
+ return luminance > 186 ? '#000' : '#fff';
1274
+ }
1275
+
1276
+ // 2. User and assistant colored chiclets
1277
+ const userLabel = c.user_name || c.user || '';
1278
+ const userColor = c.user_color || '';
1279
+ let userChip = '';
1280
+ if (userLabel) {
1281
+ const bg = userColor || '#4b5563';
1282
+ const fg = textColorForBg(bg);
1283
+ userChip = `<span class="chip user" style="background:${bg};color:${fg};">${userLabel}</span>`;
1284
+ }
1285
+
1286
+ let asstName = '';
1287
+ let asstColor = '';
1288
+ const asstId = c.assistant;
1289
+ const asstOpenAIId = c.assistant_id;
1290
+ if (this._assistants && this._assistants.length) {
1291
+ const asst = this._assistants.find(a =>
1292
+ (asstId && a && a._id === asstId) ||
1293
+ (asstOpenAIId && a && a.assistant_id === asstOpenAIId)
1294
+ );
1295
+ if (asst) {
1296
+ asstName = asst.name || asst.title || asst.assistant_id || asst._id || 'Assistant';
1297
+ asstColor = asst.assistant_color || asst.color || c.assistant_color || '';
1298
+ }
1299
+ }
1300
+ if (!asstName && (asstOpenAIId || asstId)) {
1301
+ asstName = asstOpenAIId || asstId;
1302
+ asstColor = c.assistant_color || '';
1303
+ }
1304
+ let asstChip = '';
1305
+ if (asstName) {
1306
+ const bg = asstColor || '#2563eb';
1307
+ const fg = textColorForBg(bg);
1308
+ asstChip = `<span class="chip assistant" style="background:${bg};color:${fg};">${asstName}</span>`;
1309
+ }
1310
+
1311
+ // 3. Source and # of messages
1312
+ const msgCount = Array.isArray(c.messages) ? c.messages.length : 0;
1313
+ const src = c.source || '';
1314
+ const metaParts = [];
1315
+ if (src) metaParts.push(src);
1316
+ metaParts.push(msgCount + ' msgs');
1317
+ const metaLine = metaParts.join(' Ā· ');
1318
+
1319
+ // 4. Link to the widget conversation in JOE
1320
+ let href = '#/ai_widget_conversation/' + encodeURIComponent(c._id);
1321
+ try {
1322
+ if (window && window.location && typeof window.location.href === 'string') {
1323
+ href = window.location.href.replace(window.location.hash, href);
1324
+ }
1325
+ } catch (_e) { /* ignore */ }
1326
+
1327
+ li.innerHTML = ''
1328
+ + `<div class="row title">${title}</div>`
1329
+ + `<div class="row chips">${userChip}${asstChip}</div>`
1330
+ + `<div class="row meta">${metaLine}</div>`
1331
+ + `<div class="row link"><a href="${href}" target="_blank" rel="noopener" onclick="event.stopPropagation();">open in JOE</a></div>`;
1332
+
1333
+ li.addEventListener('click', () => {
1334
+ this.applyConversationToWidget(this.widget, c._id);
1335
+ });
1336
+ listEl.appendChild(li);
1337
+ });
1338
+ } catch (e) {
1339
+ console.error('[joe-ai] error loading widget conversations', e);
1340
+ statusEl.textContent = 'Error loading conversations; see console.';
1341
+ }
1342
+ }
1343
+
1344
+ applyConversationToWidget(widget, conversationId) {
1345
+ if (!widget || !conversationId) return;
1346
+ widget.setAttribute('conversation_id', conversationId);
1347
+ widget.conversation_id = conversationId;
1348
+ if (typeof widget.loadHistory === 'function') {
1349
+ widget.loadHistory();
1350
+ } else if (widget.connectedCallback) {
1351
+ widget.connectedCallback();
1352
+ }
1353
+ if (typeof widget.persistState === 'function') {
1354
+ widget.persistState();
1355
+ }
1356
+ }
1357
+ }
1358
+
1359
+ if (!customElements.get('joe-ai-conversation-list')) {
1360
+ customElements.define('joe-ai-conversation-list', JoeAIConversationList);
1361
+ }
1362
+
693
1363
 
694
1364
  //**YES**
695
1365
  Ai.spawnChatHelper = async function(object_id,user_id=_joe.User._id,conversation_id) {
@@ -709,6 +1379,7 @@
709
1379
  newChat = true;
710
1380
  if(response.error){
711
1381
  console.error('āŒ Failed to create conversation:', response.error);
1382
+ alert('Failed to create conversation: '+response.message);
712
1383
  return;
713
1384
  }
714
1385
  }
@@ -718,6 +1389,7 @@
718
1389
  Ai.getDefaultAssistant = function() {
719
1390
 
720
1391
  Ai.default_ai = Ai.default_ai ||_joe.Data.setting.where({name:'DEFAULT_AI_ASSISTANT'})[0]||false;
1392
+ if(!Ai.default_ai){alert('No default AI assistant found');}
721
1393
  return Ai.default_ai;
722
1394
  }
723
1395
  // ========== HELPERS ==========
@@ -903,29 +1575,61 @@
903
1575
  }
904
1576
  };
905
1577
 
906
- // ---------- Autofill (Completions) ----------
907
- // Usage in schema button: onclick: "_joe.Ai.populateField(this, { prompt: 'Write a short summary', saveHistory: true })"
908
- Ai.populateField = async function(dom, options = {}){
1578
+ // ---------- Autofill (Responses) ----------
1579
+ // Usage from schema: add `ai:{ prompt:'...' }` to a field. Core will render a
1580
+ // button that calls `_joe.Ai.populateField('field_name')`. You can also call
1581
+ // `_joe.Ai.populateField(dom, { prompt:'...' })` manually from a button.
1582
+ Ai.populateField = async function(target, options = {}){
909
1583
  try{
910
1584
  const obj = _joe.current && _joe.current.object;
911
1585
  const schema = _joe.current && _joe.current.schema && _joe.current.schema.__schemaname;
912
1586
  if(!obj || !schema){ return alert('No current object/schema found'); }
913
1587
 
914
- const parentField = $(dom).parents('.joe-object-field');
915
- const inferredField = parentField.data('name');
916
- const fields = (options.fields && options.fields.length) ? options.fields : (inferredField ? [inferredField] : []);
917
- if(!fields.length){ return alert('No target field detected. Pass options.fields or place the button inside a field.'); }
1588
+ let dom = null;
1589
+ let explicitField = null;
1590
+ if (typeof target === 'string') {
1591
+ explicitField = target;
1592
+ } else {
1593
+ dom = target;
1594
+ }
1595
+
1596
+ let fields = [];
1597
+ if (options.fields && options.fields.length) {
1598
+ fields = options.fields;
1599
+ } else if (explicitField) {
1600
+ fields = [explicitField];
1601
+ } else if (dom) {
1602
+ const parentField = $(dom).parents('.joe-object-field');
1603
+ const inferredField = parentField.data('name');
1604
+ if (inferredField) { fields = [inferredField]; }
1605
+ }
1606
+ if(!fields.length){ return alert('No target field detected. Configure ai.fields or pass a field name.'); }
1607
+
1608
+ // If no prompt was passed explicitly, try to pick it up from the field's
1609
+ // schema-level ai config.
1610
+ let prompt = options.prompt || '';
1611
+ if (!prompt && fields.length === 1 && _joe && typeof _joe.getField === 'function') {
1612
+ try{
1613
+ const fd = _joe.getField(fields[0]);
1614
+ if (fd && fd.ai && fd.ai.prompt) {
1615
+ prompt = fd.ai.prompt;
1616
+ }
1617
+ }catch(_e){ /* ignore */ }
1618
+ }
918
1619
 
919
1620
  // UI feedback
920
- const originalHtml = dom.innerHTML;
921
- dom.disabled = true;
922
- dom.innerHTML = (options.loadingLabel || 'Thinking...');
1621
+ let originalHtml = null;
1622
+ if (dom && dom.innerHTML != null) {
1623
+ originalHtml = dom.innerHTML;
1624
+ dom.disabled = true;
1625
+ dom.innerHTML = (options.loadingLabel || 'Thinking...');
1626
+ }
923
1627
 
924
1628
  const payload = {
925
1629
  object_id: obj._id,
926
1630
  schema: schema,
927
1631
  fields: fields,
928
- prompt: options.prompt || '',
1632
+ prompt: prompt || '',
929
1633
  assistant_id: options.assistant_id || undefined,
930
1634
  allow_web: !!options.allowWeb,
931
1635
  save_history: !!options.saveHistory,
@@ -953,18 +1657,46 @@
953
1657
  }
954
1658
  }
955
1659
 
956
- dom.disabled = false;
957
- dom.innerHTML = originalHtml;
1660
+ if (dom && originalHtml != null) {
1661
+ dom.disabled = false;
1662
+ dom.innerHTML = originalHtml;
1663
+ }
958
1664
  }catch(e){
959
1665
  console.error('populateField error', e);
960
1666
  alert('Error running AI autofill');
961
- try{ dom.disabled = false; }catch(_e){}
1667
+ try{
1668
+ if (dom) { dom.disabled = false; }
1669
+ }catch(_e){}
962
1670
  }
963
1671
  };
964
1672
 
965
1673
  Ai.applyAutofillPatch = function(patch = {}){
966
1674
  Object.keys(patch).forEach(function(fname){
967
- Ai._setFieldValue(fname, patch[fname]);
1675
+ try {
1676
+ const $container = $(`.joe-object-field[data-name="${fname}"]`);
1677
+ if(!$container.length){ return; }
1678
+ const $el = $container.find('.joe-field').eq(0);
1679
+ if(!$el.length){ return; }
1680
+
1681
+ // Determine current value (text fields, selects, rich editors, etc.)
1682
+ let currentVal = '';
1683
+ if ($el.is('select') || $el.is('input,textarea')) {
1684
+ currentVal = $el.val();
1685
+ } else {
1686
+ currentVal = $el.text() || '';
1687
+ }
1688
+ const isBlank = !currentVal || String(currentVal).trim() === '';
1689
+
1690
+ if (!isBlank) {
1691
+ const label = fname;
1692
+ const ok = window.confirm(`Replace existing value for "${label}" with AI suggestion?`);
1693
+ if (!ok) { return; }
1694
+ }
1695
+
1696
+ Ai._setFieldValue(fname, patch[fname]);
1697
+ } catch (e) {
1698
+ console.warn('applyAutofillPatch error for field', fname, e);
1699
+ }
968
1700
  });
969
1701
  };
970
1702