UNPKG

json-object-editor

Version:

JOE the Json Object Editor | Platform Edition

1,308 lines (1,185 loc) 69.7 kB
(function(){ // Define the joeAI namespace const Ai = {}; const self = this; Ai._openChats = {}; // Conversation ID -> element Ai.default_ai = null; // Default AI assistant object // ========== COMPONENTS ========== // Simple markdown -> HTML helper used by chat UI components. // Uses global `marked` and `DOMPurify` when available, falls back to // basic escaping + <br> conversion otherwise. function renderMarkdownSafe(text) { const raw = text || ''; if (typeof window !== 'undefined' && window.marked && window.DOMPurify) { try { const html = window.marked.parse(raw); return window.DOMPurify.sanitize(html); } catch (e) { console.error('[joe-ai] markdown render error', e); // fall through to plain escape } } return raw .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/\n/g, '<br>'); } class JoeAIChatbox extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this.messages = []; this.UI={header:null,content:null,footer:null}; this.conversation = null; this.conversation_id = null; this.currentRunId = null; this.thread_id = null; this.user_id = null; } async connectedCallback() { this.conversation_id = this.getAttribute('conversation_id'); this.selected_assistant_id = Ai.default_ai?Ai.default_ai.value:null; this.ui = {}; if (!this.conversation_id) { this.renderError("Missing conversation_id"); return; } var c = await this.loadConversation(); this.getAllAssistants(); this.setupUI(); } setupUI() { // Set up UI elements and event listeners // Inject external CSS const styleLink = document.createElement('link'); styleLink.setAttribute('rel', 'stylesheet'); styleLink.setAttribute('href', '/JsonObjectEditor/css/joe-ai.css'); // Adjust path as needed this.shadowRoot.appendChild(styleLink); Ai.default_ai /*HEADER*/ Ai.getDefaultAssistant(); const assistantOptions = _joe.Data.ai_assistant.map(a => { const label = a.name || a.title || 'Assistant'; const value = a._id; const selected = value === this.selected_assistant_id ? 'selected' : ''; return `<option value="${value}" ${selected}>${label}</option>`; }).join(''); const assistantSelect = ` <label-select-wrapper> <label class="assistant-select-label" title="joe ai assistants">${_joe && _joe.SVG.icon.assistant}</label> <select id="assistant-select"> ${assistantOptions} </select> </label-select-wrapper> `; /*CONTENT*/ const chatMessages = this.messages.map(msg => this.renderMessage(msg)).reverse().join(''); // Build inner HTML in a wrapper const wrapper = document.createElement('chatbox-wrapper'); wrapper.className = 'chatbox'; wrapper.innerHTML = ` <div class="close-btn" title="Close Chatbox" >${_joe.SVG.icon.close}</div> <chat-header> <chat-title>${this.conversation.name}</chat-title> <p>${this.conversation.info||''}</p> ${assistantSelect} </chat-header> <chat-content>${chatMessages}</chat-content> <chat-footer> <textarea id="chat-input" type="text" placeholder="Type a message..."></textarea> <button id="send-button">Send</button> </chat-footer> `; this.shadowRoot.appendChild(wrapper); ['header','content','header'].map(u=>{ this.UI[u] = this.shadowRoot.querySelector('chat-'+u); }) this.UI.content.update = this.updateMessages.bind(this); setTimeout(() => { this.UI.content.scrollTop = this.UI.content.scrollHeight; }, 100); this.UI.content.scrollTop = this.UI.content.scrollHeight; this.UI.textarea = this.shadowRoot.getElementById('chat-input'); this.UI.textarea.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); // Prevent newline if needed console.log('Enter pressed!'); this.sendMessage(); } }); this.UI.sendButton = this.shadowRoot.getElementById('send-button'); this.UI.sendButton.addEventListener('click', () => this.sendMessage()); // Wire up the close button this.shadowRoot.querySelector('.close-btn').addEventListener('click', () => { //this.closeChat() this.closest('joe-ai-chatbox').closeChat(); }); // this.ui.assistant_select = this.shadowRoot.querySelector('#assistant-select'); // this.ui.assistant_select?.addEventListener('change', (e) => { // this.selected_assistant_id = e.target.value; // }); //this.selected_assistant_id = this.ui.assistant_select?.value || null; } updateMessages(messages){ messages && (this.messages = messages); const chatMessages = this.messages.map(msg => this.renderMessage(msg)).reverse().join(''); this.UI.content.innerHTML = chatMessages; this.UI.content.scrollTop = this.UI.content.scrollHeight; } async getAllAssistants() { const res = await fetch('/API/item/ai_assistant'); const result = await res.json(); if (result.error) { console.error("Error fetching assistants:", result.error); } this.allAssistants = {}; if (Array.isArray(result.item)) { result.item.map(a => { if (a._id) { this.allAssistants[a._id] = a; } if (a.openai_id) { this.allAssistants[a.openai_id] = a; // Optional dual-key if you prefer } }); } } async loadConversation() { //load conversation and messages into this try { const res = await fetch(`/API/object/ai_conversation/_id/${this.conversation_id}`); const convo = await res.json(); if (!convo || convo.error) { this.renderError("Conversation not found."); return; } this.conversation = convo; this.messages = []; if (convo.thread_id) { const resThread = await fetch(`/API/plugin/chatgpt-assistants/getThreadMessages?thread_id=${convo.thread_id}`); const threadMessages = await resThread.json(); this.messages = threadMessages?.messages || []; this.thread_id = convo.thread_id; this.user = $J.get(convo.user); } // If there is no assistant reply yet, inject a local greeting based on context. // This still shows even if we have a single PLATFORM/system card from context. const hasAssistantReply = Array.isArray(this.messages) && this.messages.some(m => m.role === 'assistant'); if (!hasAssistantReply) { try { const user = this.user || (convo.user && $J.get(convo.user)) || {}; const contextId = (convo.context_objects && convo.context_objects[0]) || null; const ctxObj = contextId ? $J.get(contextId) : null; const uname = user.name || 'there'; let target = 'this conversation'; if (ctxObj) { const label = ctxObj.name || ctxObj.title || ctxObj.label || ctxObj._id; target = `${label} (${ctxObj.itemtype || 'item'})`; } const greeting = `Hi ${uname}, I’m ready to help you with ${target}. What would you like to explore or change?`; this.messages.push({ role: 'assistant', content: greeting, created_at: Math.floor(Date.now()/1000) }); } catch(_e){} } return {conversation:convo,messages:this.messages}; } catch (err) { console.error("Chatbox load error:", err); this.renderError("Error loading conversation."); } } getAssistantInfo(id){ const assistant = this.allAssistants[id]; if (assistant) { return assistant; } else { console.warn("Assistant not found:", id); return null; } } render() { const convoName = this.conversation.name || "Untitled Conversation"; const convoInfo = this.conversation.info || ""; } processMessageText(text) { // const replaced = text.replace( // /\{\{\{BEGIN_OBJECT:(.*?)\}\}\}[\s\S]*?\{\{\{END_OBJECT:\1\}\}\}/g, // (match, cuid) => `<joe-object object_id="${cuid}"></joe-object>` // ); let didReplace = false; const replaced = text.replace( /\{\{\{BEGIN_OBJECT:(.*?)\}\}\}[\s\S]*?\{\{\{END_OBJECT:\1\}\}\}/g, (match, cuid) => { didReplace = true; return `<joe-object object_id="${cuid}"></joe-object>`; } ); return {text:replaced, replaced:didReplace}; } renderMessage(msg) { const role = msg.role || 'user'; const classes = `message ${role}`; var username = role; if(role === 'user'){ username = this.user.name||'User'; } let contentText = ''; if (Array.isArray(msg.content)) { // OpenAI style: array of parts contentText = msg.content.map(part => { if (part.type === 'text' && part.text && part.text.value) { return part.text.value; } return ''; }).join('\n'); } else if (typeof msg.content === 'string') { contentText = msg.content; } else { contentText = '[Unsupported message format]'; } const ctInfo = this.processMessageText(contentText); contentText = ctInfo.text; if(ctInfo.replaced){ username = 'platform' } // Build timestamp const createdAt = msg.created_at ? new Date(msg.created_at * 1000) : null; // OpenAI sends timestamps in seconds const timestamp = createdAt ? createdAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''; return ` <div class="${classes}"> <div class="meta"> <participant-name>${username.toUpperCase()}</participant-name> ${timestamp ? `<span class="timestamp">${timestamp}</span>` : ''}</div> <div class="content">${contentText}</div> </div> `; } renderError(message) { this.shadowRoot.innerHTML = `<div style="color:red;">${message}</div> <div class="close-btn" title="Close Chatbox" >${_joe.SVG.icon.close}</div>`; this.shadowRoot.querySelector('.close-btn').addEventListener('click', () => { //this.closeChat() this.closest('joe-ai-chatbox').closeChat(); }); } async getResponse(conversation_id,content,role,assistant_id){ const response = await fetch('/API/plugin/chatgpt-assistants/addMessage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ conversation_id: conversation_id, content: content, role: role||'system', assistant_id: assistant_id||Ai.default_ai?Ai.default_ai.value:null }) }).then(res => res.json()); } async sendMessage() {//** */ const input = this.UI.textarea; const message = input.value.trim(); if (!message) return; input.disabled = true; this.UI.sendButton.disabled = true; try { const response = await fetch('/API/plugin/chatgpt-assistants/addMessage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ conversation_id: this.conversation_id, content: message, assistant_id: this.selected_assistant_id || (Ai.default_ai ? Ai.default_ai.value : null) }) }).then(res => res.json()); if (response.success && (response.run_id || (response.runObj && response.runObj.id))) { // Use IDs from server so we don't depend on cache or stale conversation data const threadId = response.thread_id || this.conversation.thread_id; this.conversation.thread_id = threadId; this.currentRunId = response.run_id || (response.runObj && response.runObj.id); // Reload conversation (name, info, etc.), but preserve the known thread_id await this.loadConversation(); if (!this.conversation.thread_id && threadId) { this.conversation.thread_id = threadId; } this.UI.content.update(); // update messages this.startPolling(); // 🌸 start watching for assistant reply! input.value = ''; } else { alert((response.error||'Failed to send message.')+' - '+response.message); } } catch (err) { console.error('Send message error:', err); alert('Error sending message.'); } // input.disabled = false; // this.shadowRoot.getElementById('send-button').disabled = false; } startPolling() { if (this.pollingInterval) return; // Already polling // Insert thinking message this.showThinkingMessage(); // Baseline: timestamp of the latest assistant message we have so far const lastAssistantTs = (this.messages || []) .filter(m => m && m.role === 'assistant' && m.created_at) .reduce((max, m) => Math.max(max, m.created_at), 0); let attempts = 0; this.pollingInterval = setInterval(async () => { attempts++; try { const resThread = await fetch(`/API/plugin/chatgpt-assistants/getThreadMessages?thread_id=${this.conversation.thread_id}&polling=true`); const threadMessages = await resThread.json(); if (threadMessages?.messages) { const msgs = threadMessages.messages; const latestAssistantTs = msgs .filter(m => m && m.role === 'assistant' && m.created_at) .reduce((max, m) => Math.max(max, m.created_at), 0); // When we see a newer assistant message than we had before, treat it as the reply. if (latestAssistantTs && latestAssistantTs > lastAssistantTs) { this.messages = msgs; this.UI.content.update(msgs); clearInterval(this.pollingInterval); this.pollingInterval = null; this.hideThinkingMessage(); this.UI.textarea.disabled = false; this.UI.sendButton.disabled = false; return; } } if (attempts > 60) { // ~2 minutes console.warn('Thread polling timeout for assistant reply'); clearInterval(this.pollingInterval); this.pollingInterval = null; this.hideThinkingMessage(); this.UI.textarea.disabled = false; this.UI.sendButton.disabled = false; alert('Timed out waiting for assistant response.'); } } catch (err) { console.error('Polling error (thread messages):', err); clearInterval(this.pollingInterval); this.pollingInterval = null; this.hideThinkingMessage(); this.UI.textarea.disabled = false; this.UI.sendButton.disabled = false; alert('Error while checking assistant response.'); } }, 2000); } showThinkingMessage() { const messagesDiv = this.UI.content; if (!messagesDiv) return; // Pull assistant thinking text const assistant = this.getAssistantInfo(this.selected_assistant_id); const thinkingText = assistant?.assistant_thinking_text || 'Assistant is thinking...'; const div = document.createElement('div'); div.className = 'thinking-message'; div.textContent = thinkingText; div.id = 'thinking-message'; messagesDiv.appendChild(div); messagesDiv.scrollTop = messagesDiv.scrollHeight; } hideThinkingMessage() { const existing = this.shadowRoot.querySelector('#thinking-message'); if (existing) existing.remove(); } closeChat() { // Remove the element this.remove(); // Clean up from open chat registry if possible if (_joe && _joe.Ai && _joe.Ai._openChats) { delete _joe.Ai._openChats[this.conversation_id]; } } } customElements.define('joe-ai-chatbox', JoeAIChatbox); class JoeObject extends HTMLElement { constructor() { super(); this.object_id = this.getAttribute('object_id'); this.object = $J.get(this.object_id); } connectedCallback() { const id = this.getAttribute('object_id'); var sTemp = $J.schema('business')?.listView?.title||false; this.innerHTML = (sTemp)?JOE.propAsFuncOrValue(sTemp,this.object) :`<jo-title>${this.object.name}</jo-title> <jo-subtitle>${this.object.info} - ${this.object._id}</jo-subtitle>`; this.addEventListener('click', () => { // Handle click event here, e.g., open the object in a new tab or show details goJoe(_joe.search(this.object._id)[0],{schema:this.object.itemtype}) //window.open(`/object/${this.object_id}`, '_blank'); }); } } customElements.define('joe-object', JoeObject); // ---------- Joe AI Widget: embeddable chat for any site ---------- class JoeAIWidget extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this.endpoint = this.getAttribute('endpoint') || ''; // base URL to JOE; default same origin this.conversation_id = this.getAttribute('conversation_id') || null; this.assistant_id = this.getAttribute('assistant_id') || null; this.ai_assistant_id = this.getAttribute('ai_assistant_id') || null; this.user_id = this.getAttribute('user_id') || null; this.model = this.getAttribute('model') || null; this.assistant_color = this.getAttribute('assistant_color') || null; this.user_color = this.getAttribute('user_color') || null; // Optional persistence key so widgets inside dynamic layouts (like capp cards) // can restore their conversation after re-render or resize. this.persist_key = this.getAttribute('persist_key') || (this.getAttribute('source') ? ('joe-ai-widget:' + this.getAttribute('source')) : null); this.messages = []; this._ui = {}; } connectedCallback() { this.renderShell(); // If we don't have an explicit conversation_id but a persisted one exists, // restore it before deciding what to load. this.restoreFromStorageIfNeeded(); // Lazy conversation creation: // - If a conversation_id is provided, load its history. // - Otherwise, do NOT create a conversation until the user actually // sends a message. `sendMessage` will call startConversation() on // demand when needed. if (this.conversation_id) { this.loadHistory(); } else { this.setStatus('online'); } } get apiBase() { return this.endpoint || ''; } persistState() { if (!this.persist_key || typeof window === 'undefined' || !window.localStorage) return; try { const payload = { conversation_id: this.conversation_id, assistant_id: this.assistant_id, model: this.model, assistant_color: this.assistant_color }; window.localStorage.setItem(this.persist_key, JSON.stringify(payload)); } catch (_e) { /* ignore storage errors */ } } restoreFromStorageIfNeeded() { if (this.conversation_id || !this.persist_key || typeof window === 'undefined' || !window.localStorage) return; try { const raw = window.localStorage.getItem(this.persist_key); if (!raw) return; const data = JSON.parse(raw); if (!data || !data.conversation_id) return; this.conversation_id = data.conversation_id; this.setAttribute('conversation_id', this.conversation_id); this.assistant_id = data.assistant_id || this.assistant_id; this.model = data.model || this.model; this.assistant_color = data.assistant_color || this.assistant_color; } catch (_e) { /* ignore */ } } renderShell() { const style = document.createElement('style'); style.textContent = ` :host { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; display: block; /* Allow the widget to grow/shrink with its container (cards, sidebars, etc.) */ width: 100%; height: 100%; max-width: none; border: 1px solid #ddd; border-radius: 8px; box-shadow: 0 2px 6px rgba(0,0,0,0.08); overflow: hidden; background: #fff; } .wrapper { display: flex; flex-direction: column; height: 100%; } .header { padding: 8px 12px; background: #1f2933; color: #f9fafb; font-size: 13px; display: flex; align-items: center; justify-content: space-between; } .title { font-weight: 600; } .status { font-size: 11px; opacity: 0.8; } .messages { padding: 10px; /* Fill remaining space between header and footer */ flex: 1 1 auto; min-height: 0; overflow-y: auto; background: #f5f7fa; font-size: 13px; } .msg { margin-bottom: 8px; max-width: 90%; clear: both; } .msg.user { text-align: right; margin-left: auto; } .bubble { display: inline-block; padding: 6px 8px; border-radius: 5px; line-height: 1.4; max-width: 100%; overflow-x: auto; } .user .bubble { background: var(--joe-ai-user-bg, #2563eb); color: #fff; } .assistant .bubble { background: var(--joe-ai-assistant-bg, #e5e7eb); color: #111827; } .msg.assistant.tools-used .bubble { background: #fef3c7; color: #92400e; font-size: 11px; } .footer { border-top: 1px solid #e5e7eb; padding: 6px; display: flex; gap: 6px; align-items: center; } textarea { flex: 1; resize: none; border-radius: 6px; border: 1px solid #d1d5db; padding: 6px 8px; font-size: 13px; min-height: 34px; max-height: 80px; } button { border-radius: 6px; border: none; background: #2563eb; color: #fff; padding: 6px 10px; font-size: 13px; cursor: pointer; white-space: nowrap; } button:disabled { opacity: 0.6; cursor: default; } `; const wrapper = document.createElement('div'); wrapper.className = 'wrapper'; wrapper.innerHTML = ` <div class="header"> <div class="title">${this.getAttribute('title') || 'AI Assistant'}</div> <div class="status" id="status">connecting…</div> </div> <div class="messages" id="messages"></div> <div class="footer"> <textarea id="input" placeholder="${this.getAttribute('placeholder') || 'Ask me anything…'}"></textarea> <button id="send">Send</button> </div> `; this.shadowRoot.innerHTML = ''; this.shadowRoot.appendChild(style); this.shadowRoot.appendChild(wrapper); this._ui.messages = this.shadowRoot.getElementById('messages'); this._ui.status = this.shadowRoot.getElementById('status'); this._ui.input = this.shadowRoot.getElementById('input'); this._ui.send = this.shadowRoot.getElementById('send'); this._ui.send.addEventListener('click', () => this.sendMessage()); this._ui.input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.sendMessage(); } }); this.applyThemeColors(); } applyThemeColors() { if (!this.shadowRoot || !this.shadowRoot.host) return; const host = this.shadowRoot.host; if (this.assistant_color) { host.style.setProperty('--joe-ai-assistant-bg', this.assistant_color); } if (this.user_color) { host.style.setProperty('--joe-ai-user-bg', this.user_color); } } setStatus(text) { if (!this._ui.status) return; const cid = this.conversation_id || ''; // Show conversation id when available, e.g. "cuid123 - online" this._ui.status.textContent = cid ? (cid + ' - ' + text) : text; } renderMessages() { if (!this._ui.messages) return; this._ui.messages.innerHTML = (this.messages || []).map(m => { const role = m.role || 'assistant'; const extra = m.meta === 'tools_used' ? ' tools-used' : ''; const html = renderMarkdownSafe(m.content || ''); return `<div class="msg ${role}${extra}"><div class="bubble">${html}</div></div>`; }).join(''); this._ui.messages.scrollTop = this._ui.messages.scrollHeight; } // Resolve the logical user for this widget instance. // Priority: // 1) Explicit user_id attribute on the element (fetch /API/item/user/_id/:id) // 2) Page-level window._aiWidgetUser (used by ai-widget-test.html) // 3) null (caller can still force a user_color theme) async resolveUser() { if (this._resolvedUser) { return this._resolvedUser; } // 1) If the hosting page passed a user_id attribute, look up that user via JOE. if (this.user_id) { try { const res = await fetch( this.apiBase + '/API/item/user/_id/' + encodeURIComponent(this.user_id), { credentials: 'include' } ); const data = await res.json(); const u = (data && data.item) || null; if (u && u._id) { this._resolvedUser = u; return u; } } catch (e) { console.error('JoeAIWidget.resolveUser: failed to load user by id', this.user_id, e); } } // 2) Fall back to a page-global user (ai-widget-test.html populates this). if (typeof window !== 'undefined' && window._aiWidgetUser) { this._resolvedUser = window._aiWidgetUser; return this._resolvedUser; } return null; } async startConversation() { try { this.setStatus('connecting…'); const payload = { model: this.model || undefined, ai_assistant_id: this.getAttribute('ai_assistant_id') || undefined, source: this.getAttribute('source') || 'widget' }; // Resolve the effective user for this widget and pass id/name/color // explicitly to the server. This works for: // - ai-widget-test.html (which sets window._aiWidgetUser) // - JOE pages or external sites that pass a user_id attribute try { const globalUser = await this.resolveUser(); if (globalUser) { payload.user_id = globalUser._id; payload.user_name = globalUser.fullname || globalUser.name; if (globalUser.color) { payload.user_color = globalUser.color; } } else if (this.user_color) { payload.user_color = this.user_color; } } catch (_e) { /* ignore */ } const resp = await fetch(this.apiBase + '/API/plugin/chatgpt/widgetStart', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }).then(r => r.json()); if (!resp || resp.success !== true) { const msg = (resp && (resp.error || resp.message)) || 'Failed to start conversation'; this.setStatus('error: ' + msg); this.messages.push({ role:'assistant', content:'[Error starting conversation: '+msg+']', created_at:new Date().toISOString() }); this.renderMessages(); console.error('widgetStart error', { payload, response: resp }); return; } this.conversation_id = resp.conversation_id; this.setAttribute('conversation_id', this.conversation_id); this.assistant_id = resp.assistant_id || this.assistant_id; this.model = resp.model || this.model; this.assistant_color = resp.assistant_color || this.assistant_color; this.applyThemeColors(); this.messages = []; this.renderMessages(); this.setStatus('online'); this.persistState(); } catch (e) { console.error('widgetStart exception', e); this.setStatus('error'); } } async loadHistory() { try { this.setStatus('loading…'); const resp = await fetch( this.apiBase + '/API/plugin/chatgpt/widgetHistory?conversation_id=' + encodeURIComponent(this.conversation_id) ).then(r => r.json()); if (!resp || resp.success !== true) { console.warn('widgetHistory non-success response', resp); this.setStatus('online'); return; } this.assistant_id = resp.assistant_id || this.assistant_id; this.model = resp.model || this.model; this.assistant_color = resp.assistant_color || this.assistant_color; this.applyThemeColors(); this.messages = resp.messages || []; this.renderMessages(); this.setStatus('online'); this.persistState(); } catch (e) { console.error('widgetHistory exception', e); this.setStatus('online'); } } async sendMessage() { const input = this._ui.input; if (!input) return; const text = input.value.trim(); if (!text) return; if (!this.conversation_id) { await this.startConversation(); if (!this.conversation_id) return; } input.value = ''; this._ui.send.disabled = true; const userMsg = { role: 'user', content: text, created_at: new Date().toISOString() }; this.messages.push(userMsg); this.renderMessages(); this.setStatus('thinking…'); try { const payload = { conversation_id: this.conversation_id, content: text, role: 'user', assistant_id: this.assistant_id || undefined, model: this.model || undefined }; try { const globalUser = await this.resolveUser(); if (globalUser) { payload.user_id = globalUser._id; payload.user_name = globalUser.fullname || globalUser.name; if (globalUser.color) { payload.user_color = globalUser.color; } } else if (this.user_color) { payload.user_color = this.user_color; } } catch (_e) { /* ignore */ } const resp = await fetch(this.apiBase + '/API/plugin/chatgpt/widgetMessage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }).then(r => r.json()); if (!resp || resp.success !== true) { const msg = (resp && (resp.error || resp.message)) || 'Failed to send message'; console.error('widgetMessage error', { payload, response: resp }); this.messages.push({ role:'assistant', content:'[Error: '+msg+']', created_at:new Date().toISOString() }); this.renderMessages(); this.setStatus('error: ' + msg); } else { this.assistant_id = resp.assistant_id || this.assistant_id; this.model = resp.model || this.model; this.assistant_color = resp.assistant_color || this.assistant_color; this.applyThemeColors(); this.messages = resp.messages || this.messages; this.renderMessages(); this.setStatus('online'); this.persistState(); } } catch (e) { console.error('widgetMessage exception', e); this.setStatus('error'); } finally { this._ui.send.disabled = false; } } } if (!customElements.get('joe-ai-widget')) { customElements.define('joe-ai-widget', JoeAIWidget); } // ---------- Assistant picker: small selector component for joe-ai-widget ---------- class JoeAIAssistantPicker extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this._ui = {}; this._assistants = []; this._defaultId = null; } connectedCallback() { this.renderShell(); this.init(); } get widget() { const targetId = this.getAttribute('for_widget'); if (targetId) { return document.getElementById(targetId); } // Fallback: nearest joe-ai-widget in the same card/container return this.closest('capp-card,div')?.querySelector('joe-ai-widget') || null; } renderShell() { const style = document.createElement('style'); style.textContent = ` :host { display: block; margin-bottom: 6px; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 12px; } .row { display: flex; align-items: center; justify-content: space-between; gap: 6px; } label { color: #374151; } select { margin-left: 4px; padding: 2px 4px; font-size: 12px; } .hint { font-size: 11px; color: #6b7280; margin-left: 4px; } `; const wrapper = document.createElement('div'); wrapper.className = 'row'; wrapper.innerHTML = ` <div> <label>Assistant:</label> <select id="assistant-select"></select> <span id="assistant-hint" class="hint"></span> </div> <div> <button id="new-convo" style="font-size:11px;padding:2px 8px;">New conversation</button> </div> `; this.shadowRoot.innerHTML = ''; this.shadowRoot.appendChild(style); this.shadowRoot.appendChild(wrapper); this._ui.select = this.shadowRoot.getElementById('assistant-select'); this._ui.hint = this.shadowRoot.getElementById('assistant-hint'); this._ui.newConvo = this.shadowRoot.getElementById('new-convo'); } async init() { const select = this._ui.select; const hint = this._ui.hint; const widget = this.widget; if (!select || !widget) return; try { const [assistantsResp, settingsResp] = await Promise.all([ fetch('/API/item/ai_assistant', { credentials: 'include' }).then(r => r.json()), fetch('/API/item/setting', { credentials: 'include' }).then(r => r.json()) ]); this._assistants = Array.isArray(assistantsResp.item) ? assistantsResp.item : []; const settings = Array.isArray(settingsResp.item) ? settingsResp.item : []; const def = settings.find(s => s && s.name === 'DEFAULT_AI_ASSISTANT'); this._defaultId = def && def.value; } catch (e) { console.error('[joe-ai] AssistantPicker init error', e); this._assistants = []; } select.innerHTML = ''; const noneOption = document.createElement('option'); noneOption.value = ''; noneOption.textContent = 'None (model only)'; select.appendChild(noneOption); this._assistants.forEach(a => { const opt = document.createElement('option'); opt.value = a._id; opt.textContent = a.name || a.title || a._id; if (a._id === this._defaultId) { opt.textContent += ' (default)'; } select.appendChild(opt); }); let initialId = ''; if (widget.getAttribute('ai_assistant_id')) { initialId = widget.getAttribute('ai_assistant_id'); hint && (hint.textContent = 'Using assistant from widget attribute.'); } else if (this._defaultId && this._assistants.some(a => a && a._id === this._defaultId)) { initialId = this._defaultId; hint && (hint.textContent = 'Using DEFAULT_AI_ASSISTANT.'); } else { hint && (hint.textContent = this._assistants.length ? 'No default; using model only.' : 'No assistants defined; using model only.'); } select.value = initialId; this.applyAssistantToWidget(widget, initialId); select.addEventListener('change', () => { this.applyAssistantToWidget(widget, select.value); }); if (this._ui.newConvo) { this._ui.newConvo.addEventListener('click', () => { this.startNewConversation(widget); }); } } startNewConversation(widget) { try { if (!widget) return; // Clear persisted state and conversation id so a fresh widgetStart will happen if (widget.persist_key && typeof window !== 'undefined' && window.localStorage) { try { window.localStorage.removeItem(widget.persist_key); } catch (_e) { /* ignore */ } } widget.removeAttribute('conversation_id'); widget.conversation_id = null; if (Array.isArray(widget.messages)) { widget.messages = []; } if (typeof widget.renderMessages === 'function') { widget.renderMessages(); } if (typeof widget.setStatus === 'function') { widget.setStatus('online'); } } catch (e) { console.error('[joe-ai] error starting new conversation from picker', e); } } applyAssistantToWidget(widget, assistantId) { try { if (!widget) return; const val = assistantId || ''; if (val) { widget.setAttribute('ai_assistant_id', val); } else { widget.removeAttribute('ai_assistant_id'); } // Reset conversation and clear persisted state so a new chat starts if (widget.persist_key && typeof window !== 'undefined' && window.localStorage) { try { window.localStorage.removeItem(widget.persist_key); } catch (_e) { /* ignore */ } } widget.removeAttribute('conversation_id'); widget.conversation_id = null; if (typeof widget.setStatus === 'function') { widget.setStatus('online'); } } catch (e) { console.error('[joe-ai] error applying assistant selection', e); } } } if (!customElements.get('joe-ai-assistant-picker')) { customElements.define('joe-ai-assistant-picker', JoeAIAssistantPicker); } // ---------- Conversation list: clickable ai_widget_conversation list ---------- class JoeAIConversationList extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this._ui = {}; this._assistants = []; } connectedCallback() { this.renderShell(); this.refreshConversations(); } get widget() { const targetId = this.getAttribute('for_widget'); if (targetId) { return document.getElementById(targetId); } return this.closest('capp-card,div')?.querySelector('joe-ai-widget') || null; } renderShell() { const style = document.createElement('style'); style.textContent = ` :host { display: block; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 12px; } .container { display: flex; flex-direction: column; height: 100%; } .header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; font-weight: 600; } .status { font-size: 11px; margin-bottom: 4px; } .row { margin-bottom: 2px; } .row.chips { display: flex; gap: 4px; flex-wrap: wrap; } .chip { display: inline-block; padding: 1px 6px; border-radius: 999px; font-size: 10px; line-height: 1.4; white-space: nowrap; } .row.link a { font-size: 11px; } ul { list-style: none; padding: 0; margin: 0; flex: 1 1 auto; overflow-y: auto; } li { cursor: pointer; padding: 3px 0; border-bottom: 1px solid #e5e7eb; } li:hover { background: #f9fafb; } `; const wrapper = document.createElement('div'); wrapper.className = 'container'; wrapper.innerHTML = ` <div class="header"> <span>Widget Conversations</span> <button id="refresh" style="font-size:11px;padding:2px 6px;">Refresh</button> </div> <div id="status" class="status"></div> <ul id="list"></ul> `; this.shadowRoot.innerHTML = ''; this.shadowRoot.appendChild(style); this.shadowRoot.appendChild(wrapper); this._ui.status = this.shadowRoot.getElementById('status'); this._ui.list = this.shadowRoot.getElementById('list'); this._ui.refresh = this.shadowRoot.getElementById('refresh'); this._ui.refresh.addEventListener('click', () => this.refreshConversations()); } async refreshConversations() { const statusEl = this._ui.status; const listEl = this._ui.list; if (!statusEl || !listEl) return; statusEl.textContent = 'Loading conversations...'; listEl.innerHTML = ''; try { // Load assistants once for name/color lookup if (!this._assistants || !this._assistants.length) { try { const aRes = await fetch('/API/item/ai_assistant', { credentials: 'include' }); const aData = await aRes.json(); this._assistants = Array.isArray(aData.item) ? aData.item : []; } catch (_e) { this._assistants = []; } } const res = await fetch('/API/item/ai_widget_conversation', { credentials: 'include' }); const data = await res.json(); let items = Array.isArray(data.item) ? data.item : []; const allItems = items.slice(); const sourceFilter = this.getAttribute('source'); let filtered = false; if (sourceFilter) { items = items.filter(c => c.source === sourceFilter); filtered = true; } if (!items.length) { if (filtered && allItems.length) { // Fallback: no conversations for this source; show all instead. items = allItems; statusEl.textContent = 'No conversations for source "' + sourceFilter + '". Showing all widget conversations.'; } else { statusEl.textContent = 'No widget conversations found.'; return; } } items.sort((a, b) => { const da = a.last_message_at || a.joeUpdated || a.created || ''; const db = b.last_message_at || b.joeUpdated || b.created || ''; return db.localeCompare(da); }); statusEl.textContent = 'Click a conversation to resume it.'; items.forEach(c => { const li = document.createElement('li'); li.dataset.id = c._id; const ts = c.last_message_at || c.joeUpdated || c.created || ''; const prettyTs = (typeof _joe !== 'undefined' && _joe.Utils && typeof _joe.Utils.prettyPrintDTS === 'function') ? _joe.Utils.prettyPrintDTS(ts) : ts; // 1. Conversation title: name or pretty date-time const title = c.name || prettyTs || c._id; // Helper to choose readable foreground for a hex bg function textColorForBg(hex) { if (!hex || typeof hex !== 'string' || !/^#?[0-9a-fA-F]{6}$/.test(hex)) return '#000'; const h = hex[0] === '#' ? hex.slice(1) : hex; const n = parseInt(h, 16); const r = (n >> 16) & 0xff; const g = (n >> 8) & 0xff; const b = n & 0xff; const luminance = r * 0.299 + g * 0.587 + b * 0.114; return luminance > 186 ? '#000' : '#fff'; } // 2. User and assistant colored chiclets const userLabel = c.user_name || c.user || ''; const userColor = c.user_color || ''; let userChip = ''; if (userLabel) { const bg = userColor || '#4b5563'; const fg = textColorForBg(bg); userChip = `<span class="chip user" style="background:${bg};color:${fg};">${userLabel}</span>`; } let asstName = ''; let asstColor = ''; const asstId = c.assistant; const asstOpenAIId = c.assistant_id; if (this._assistants && this._assistants.length) { const asst = this._assistants.find(a => (asstId && a && a._id === asstId) || (asstOpenAIId && a && a.assistant_id === asstOpenAIId) ); if (asst) { asstName = asst.name || asst.title || asst.assistant_id || asst._id || 'Assistant'; asstColor = asst.assistant_color || asst.color || c.assistant_color || ''; } } if (!asstName && (asstOpenAIId || asstId)) { asstName = asstOpenAIId || asstId; asstColor = c.assistant_color || ''; } let asstChip = ''; if (asstName) { const bg = asstColor || '#2563eb'; const fg = textColorForBg(bg); asstChip