json-object-editor
Version:
JOE the Json Object Editor | Platform Edition
517 lines (435 loc) • 19.5 kB
JavaScript
(function(){
// Define the joeAI namespace
const Ai = {};
const self = this;
Ai._openChats = {}; // Conversation ID -> element
Ai.default_ai = null; // Default AI assistant ID
// ========== COMPONENTS ==========
class JoeAIChatbox extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.messages = [];
}
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;
}
this.loadConversation();
this.getAllAssistants();
}
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() {
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.render();
} 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 || "";
const chatMessages = this.messages.map(msg => this.renderMessage(msg)).reverse().join('');
const assistantOptions = (this.conversation.assistants || []).map(a => {
const meta = this.getAssistantInfo(a) || {};
const label = meta.name || a.name || a.title || 'Assistant';
const value = meta._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>
`;
// 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);
// Build inner HTML in a wrapper
const wrapper = document.createElement('div');
wrapper.className = 'chatbox';
wrapper.innerHTML = `
<div class="close-btn" title="Close Chatbox" >${_joe.SVG.icon.close}</div>
<div class="header">
<h2>${convoName}</h2>
<p>${convoInfo}</p>
${assistantSelect}
</div>
<div class="messages">${chatMessages}</div>
<div class="inputRow">
<textarea id="chat-input" type="text" placeholder="Type a message..."></textarea>
<button id="send-button">Send</button>
</div>
`;
this.shadowRoot.appendChild(wrapper);
const messagesDiv = this.shadowRoot.querySelector('.messages');
messagesDiv.scrollTop = messagesDiv.scrollHeight;
this.shadowRoot.getElementById('send-button').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;
}
renderMessage(msg) {
const role = msg.role || 'user';
const classes = `message ${role}`;
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]';
}
// 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>${role.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>`;
}
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.shadowRoot.getElementById('chat-input');
const message = input.value.trim();
if (!message) return;
input.disabled = true;
this.shadowRoot.getElementById('send-button').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.runObj) {
this.currentRunId = response.runObj.id; // Store the run ID for polling
await this.loadConversation(); // reload messages
this.startPolling(); // 🌸 start watching for assistant reply!
input.value = '';
} else {
alert('Failed to send 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();
this.pollingInterval = setInterval(async () => {
const runRes = await fetch(`/API/plugin/chatgpt-assistants/getRunStatus?thread_id=${this.conversation.thread_id}&run_id=${this.currentRunId}`);
const run = await runRes.json();
if (run.status === 'completed') {
const resThread = await fetch(`/API/plugin/chatgpt-assistants/getThreadMessages?thread_id=${this.conversation.thread_id}`);
//const activeAssistant = this.conversation.assistants.find(a => a.openai_id === run.assistant_id);
const threadMessages = await resThread.json();
if (threadMessages?.messages) {
this.messages = threadMessages.messages;
//this.render();
}
clearInterval(this.pollingInterval);
this.pollingInterval = null;
this.hideThinkingMessage();
}
}, 2000);
}
showThinkingMessage() {
const messagesDiv = this.shadowRoot.querySelector('.messages');
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);
//**YES**
Ai.spawnChatHelper = async function(object_id,user_id=_joe.User._id,conversation_id) {
//if not conversation_id, create a new one
let convo_id = conversation_id;
var newChat = false;
if(!convo_id){
const response = await fetch('/API/plugin/chatgpt-assistants/createConversation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
object_id,
user_id
})
}).then(res => res.json());
convo_id = response?.conversation?._id;
newChat = true;
if(response.error){
console.error('❌ Failed to create conversation:', response.error);
return;
}
}
await _joe.Ai.spawnContextualChat(convo_id,{object_id,newChat});
}
// ========== HELPERS ==========
Ai.spawnContextualChat = async function(conversationId, options = {}) {
if (!conversationId) {
console.warn("Missing conversation ID for chat spawn.");
return;
}
Ai.default_ai = _joe.Data.setting.where({name:'DEFAULT_AI_ASSISTANT'})[0]||false;
// 1. Check if chat already open
if (Ai._openChats[conversationId]) {
console.log("Chatbox already open for", conversationId);
Ai._openChats[conversationId].scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
try {
const flattened = _joe.Object.flatten(options.object_id);
if (options.newChat) {
// 2. Prepare context
const contextInstructions = _joe.Ai.generateContextInstructions(flattened,options.object_id);
// 3. Inject context into backend
const contextResult = await fetch('/API/plugin/chatgpt-assistants/addMessage', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
conversation_id: conversationId,
role: 'system',
content: contextInstructions,
assistant_id: Ai.default_ai.value
})
}).then(res => res.json());
if (!contextResult || contextResult.error) {
console.error('❌ Failed to inject context:', contextResult?.error);
return;
}
}
// 4. Create new chatbox
const chat = document.createElement('joe-ai-chatbox');
chat.setAttribute('conversation_id', conversationId);
const screenWidth = window.innerWidth;
if(screenWidth <= 768){
chat.setAttribute('mobile', 'true');
chat.style.width = 'auto';
chat.style.left = '0px';
}
else{
chat.setAttribute('mobile', 'false');
chat.style.width = options.width || '640px';
chat.style.left = 'auto';
}
// Apply styles
//chat.style.height = options.height || '420px';
chat.style.bottom = options.bottom || '50px';
chat.style.right = options.right || '0px';
chat.style.top = options.top || '100px';
chat.style.position = 'fixed';
chat.style.zIndex = '10000';
chat.style.background = '#efefef';
chat.style.border = '1px solid #fff';
chat.style.borderRadius = '8px';
chat.style.boxShadow = '0px 1px 4px rgba(0, 0, 0, 0.3)';
chat.style.padding = '5px';
chat.style.margin = '5px';
document.body.appendChild(chat);
// 5. Track it
Ai._openChats[conversationId] = chat;
if (options.newChat) {
// 6. Show soft local UI message
_joe.Ai.injectSystemMessage(conversationId, `Context injected: ${flattened.name || flattened.title || 'Object'} (${flattened._id})`);
}
} catch (err) {
console.error('❌ spawnChat context injection failed:', err);
}
};
/* Ai.spawnChat = function(conversationId, options = {}) {
if (!conversationId) {
console.warn("Missing conversation ID for chat spawn.");
return;
}
// 1. Check if chatbox already open
if (Ai._openChats[conversationId]) {
console.log("Chatbox already open for", conversationId);
Ai._openChats[conversationId].scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
// 2. Create new chatbox
const chat = document.createElement('joe-ai-chatbox');
chat.setAttribute('conversation_id', conversationId);
const flattened = _joe.Object.flatten();
const contextInstructions = _joe.Ai.generateContextInstructions(flattened);
// Actually inject into AI backend (for assistant awareness) if you have that later
// For now: silently show a soft system bubble
_joe.Ai.injectSystemMessage(conversationId, `Context injected: ${flattened.name || flattened.title || 'Object'} (${flattened._id})`);
// Apply styles
chat.style.width = options.width || '400px';
chat.style.height = options.height || '420px';
chat.style.bottom = options.bottom || '20px';
chat.style.right = options.right || '20px';
chat.style.position = 'fixed';
chat.style.zIndex = '10000';
chat.style.background = '#efefef';
chat.style.border = '1px solid #fff';
chat.style.borderRadius = '8px';
chat.style.boxShadow = '0px 2px 10px rgba(0,0,0,0.1)';
chat.style.padding = '5px';
document.body.appendChild(chat);
// 3. Track it
Ai._openChats[conversationId] = chat;
return chat;
// 4. Optionally clean up when chatbox is removed (if you wire close buttons later)
};
*/
Ai.generateContextInstructions = function(flattenedObj,object_id) {
if (!flattenedObj) return '';
let context = `{{{BEGIN_OBJECT:${object_id}}}}`+
"Context: You are assisting the user with the following object:\n\n";
context += JSON.stringify(flattenedObj, null, 2) + "\n\n";
// for (const [key, value] of Object.entries(flattenedObj)) {
// if (typeof value === 'object' && value !== null) {
// context += `- ${key}: (linked object)\n`;
// for (const [subkey, subval] of Object.entries(value)) {
// context += ` • ${subkey}: ${subval}\n`;
// }
// } else {
// context += `- ${key}: ${value}\n`;
// }
// }
context += `\nAlways refer to this context when answering questions or completing tasks related to this object.\n`+
`{{{END_OBJECT:${object_id}}}}`;
return context;
};
Ai.injectSystemMessage = async function(conversationId, text) {
if (!conversationId || !text) return;
try {
// Create a system-style message object
const messageObj = {
conversation_id: conversationId,
role: 'joe',
content: text,
created: new Date().toISOString()
};
// You could either push this directly into chatbox if loaded, or update server messages if you have backend ready
const chatbox = document.querySelector(`joe-ai-chatbox[conversation_id="${conversationId}"]`);
if (chatbox) {
if (!chatbox.messages) {
chatbox.messages = [];
}
chatbox.messages.push(messageObj);
// Optionally trigger a soft re-render of chatbox if needed
if (typeof chatbox.renderMessages === 'function') {
chatbox.renderMessages();
}
}
} catch (err) {
console.error("❌ injectSystemMessage failed:", err);
}
};
// Attach AI to _joe
if (window._joe) {
_joe.Ai = Ai;
} else {
console.warn('joeAI.js loaded before _joe was ready.');
}
})();