UNPKG

json-object-editor

Version:

JOE the Json Object Editor | Platform Edition

633 lines (529 loc) 23.6 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 ========== 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); } 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.runObj) { this.currentRunId = response.runObj.id; // Store the run ID for polling await this.loadConversation(); // reload messages this.UI.content.update(); // update 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.UI.content.update(threadMessages.messages); //this.messages = threadMessages.messages; //this.render(); } clearInterval(this.pollingInterval); this.pollingInterval = null; this.hideThinkingMessage(); this.UI.textarea.disabled = false; this.UI.sendButton.disabled = false; } }, 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); //**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}); } Ai.getDefaultAssistant = function() { Ai.default_ai = Ai.default_ai ||_joe.Data.setting.where({name:'DEFAULT_AI_ASSISTANT'})[0]||false; return Ai.default_ai; } // ========== HELPERS ========== Ai.spawnContextualChat = async function(conversationId, options = {}) { if (!conversationId) { console.warn("Missing conversation ID for chat spawn."); return; } Ai.getDefaultAssistant(); // 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: 'JOE', content: contextInstructions, assistant_id: Ai.default_ai.value, object_id: options.object_id }) }).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 || '50px'; 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.'); } })();