json-object-editor
Version:
JOE the Json Object Editor | Platform Edition
1,308 lines (1,185 loc) • 69.7 kB
JavaScript
(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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.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