json-object-editor 0.10.654 → 0.10.657
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -1
- package/_www/mcp-test.html +287 -276
- package/css/joe-styles.css +6 -0
- package/css/joe.css +7 -1
- package/css/joe.min.css +1 -1
- package/js/JsonObjectEditor.jquery.craydent.js +56 -12
- package/js/joe-ai.js +2075 -2052
- package/js/joe.js +57 -13
- package/js/joe.min.js +1 -1
- package/package.json +1 -1
- package/readme.md +30 -1
- package/server/modules/MCP.js +1364 -1237
- package/server/modules/Sites.js +79 -0
- package/server/modules/Storage.js +28 -1
- package/server/plugins/chatgpt.js +1732 -1495
- package/server/schemas/ai_prompt.js +389 -324
- package/server/schemas/ai_response.js +414 -374
package/js/joe-ai.js
CHANGED
|
@@ -1,2053 +1,2076 @@
|
|
|
1
|
-
(function(){
|
|
2
|
-
// Define the joeAI namespace
|
|
3
|
-
const Ai = {};
|
|
4
|
-
const self = this;
|
|
5
|
-
Ai._openChats = {}; // Conversation ID -> element
|
|
6
|
-
Ai.default_ai = null; // Default AI assistant object
|
|
7
|
-
// ========== COMPONENTS ==========
|
|
8
|
-
|
|
9
|
-
// Simple markdown -> HTML helper used by chat UI components.
|
|
10
|
-
// Uses global `marked` and `DOMPurify` when available, falls back to
|
|
11
|
-
// basic escaping + <br> conversion otherwise.
|
|
12
|
-
function renderMarkdownSafe(text) {
|
|
13
|
-
const raw = text || '';
|
|
14
|
-
if (typeof window !== 'undefined' && window.marked && window.DOMPurify) {
|
|
15
|
-
try {
|
|
16
|
-
const html = window.marked.parse(raw);
|
|
17
|
-
return window.DOMPurify.sanitize(html);
|
|
18
|
-
} catch (e) {
|
|
19
|
-
console.error('[joe-ai] markdown render error', e);
|
|
20
|
-
// fall through to plain escape
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
return raw
|
|
24
|
-
.replace(/&/g, '&')
|
|
25
|
-
.replace(/</g, '<')
|
|
26
|
-
.replace(/>/g, '>')
|
|
27
|
-
.replace(/\n/g, '<br>');
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
class JoeAIChatbox extends HTMLElement {
|
|
31
|
-
constructor() {
|
|
32
|
-
super();
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
this.attachShadow({ mode: 'open' });
|
|
36
|
-
this.messages = [];
|
|
37
|
-
this.UI={header:null,content:null,footer:null};
|
|
38
|
-
this.conversation = null;
|
|
39
|
-
this.conversation_id = null;
|
|
40
|
-
this.currentRunId = null;
|
|
41
|
-
this.thread_id = null;
|
|
42
|
-
this.user_id = null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
async connectedCallback() {
|
|
46
|
-
this.conversation_id = this.getAttribute('conversation_id');
|
|
47
|
-
|
|
48
|
-
this.selected_assistant_id = Ai.default_ai?Ai.default_ai.value:null;
|
|
49
|
-
this.ui = {};
|
|
50
|
-
if (!this.conversation_id) {
|
|
51
|
-
this.renderError("Missing conversation_id");
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
var c = await this.loadConversation();
|
|
56
|
-
this.getAllAssistants();
|
|
57
|
-
this.setupUI();
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
setupUI() {
|
|
61
|
-
// Set up UI elements and event listeners
|
|
62
|
-
// Inject external CSS
|
|
63
|
-
const styleLink = document.createElement('link');
|
|
64
|
-
styleLink.setAttribute('rel', 'stylesheet');
|
|
65
|
-
styleLink.setAttribute('href', '/JsonObjectEditor/css/joe-ai.css'); // Adjust path as needed
|
|
66
|
-
this.shadowRoot.appendChild(styleLink);
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
Ai.default_ai
|
|
70
|
-
/*HEADER*/
|
|
71
|
-
Ai.getDefaultAssistant();
|
|
72
|
-
const assistantOptions = _joe.Data.ai_assistant.map(a => {
|
|
73
|
-
|
|
74
|
-
const label = a.name || a.title || 'Assistant';
|
|
75
|
-
const value = a._id;
|
|
76
|
-
const selected = value === this.selected_assistant_id ? 'selected' : '';
|
|
77
|
-
|
|
78
|
-
return `<option value="${value}" ${selected}>${label}</option>`;
|
|
79
|
-
}).join('');
|
|
80
|
-
|
|
81
|
-
const assistantSelect = `
|
|
82
|
-
<label-select-wrapper>
|
|
83
|
-
<label class="assistant-select-label" title="joe ai assistants">${_joe && _joe.SVG.icon.assistant}</label>
|
|
84
|
-
<select id="assistant-select">
|
|
85
|
-
${assistantOptions}
|
|
86
|
-
</select>
|
|
87
|
-
</label-select-wrapper>
|
|
88
|
-
`;
|
|
89
|
-
|
|
90
|
-
/*CONTENT*/
|
|
91
|
-
const chatMessages = this.messages.map(msg => this.renderMessage(msg)).reverse().join('');
|
|
92
|
-
|
|
93
|
-
// Build inner HTML in a wrapper
|
|
94
|
-
const wrapper = document.createElement('chatbox-wrapper');
|
|
95
|
-
wrapper.className = 'chatbox';
|
|
96
|
-
wrapper.innerHTML = `
|
|
97
|
-
<div class="close-btn" title="Close Chatbox" >${_joe.SVG.icon.close}</div>
|
|
98
|
-
<chat-header>
|
|
99
|
-
<chat-title>${this.conversation.name}</chat-title>
|
|
100
|
-
<p>${this.conversation.info||''}</p>
|
|
101
|
-
${assistantSelect}
|
|
102
|
-
</chat-header>
|
|
103
|
-
<chat-content>${chatMessages}</chat-content>
|
|
104
|
-
<chat-footer>
|
|
105
|
-
<textarea id="chat-input" type="text" placeholder="Type a message..."></textarea>
|
|
106
|
-
<button id="send-button">Send</button>
|
|
107
|
-
</chat-footer>
|
|
108
|
-
`;
|
|
109
|
-
|
|
110
|
-
this.shadowRoot.appendChild(wrapper);
|
|
111
|
-
|
|
112
|
-
['header','content','header'].map(u=>{
|
|
113
|
-
this.UI[u] = this.shadowRoot.querySelector('chat-'+u);
|
|
114
|
-
})
|
|
115
|
-
this.UI.content.update = this.updateMessages.bind(this);
|
|
116
|
-
setTimeout(() => {
|
|
117
|
-
this.UI.content.scrollTop = this.UI.content.scrollHeight;
|
|
118
|
-
}, 100);
|
|
119
|
-
this.UI.content.scrollTop = this.UI.content.scrollHeight;
|
|
120
|
-
|
|
121
|
-
this.UI.textarea = this.shadowRoot.getElementById('chat-input');
|
|
122
|
-
this.UI.textarea.addEventListener('keydown', (e) => {
|
|
123
|
-
if (e.key === 'Enter' && !e.shiftKey) {
|
|
124
|
-
e.preventDefault(); // Prevent newline if needed
|
|
125
|
-
console.log('Enter pressed!');
|
|
126
|
-
this.sendMessage();
|
|
127
|
-
}
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
this.UI.sendButton = this.shadowRoot.getElementById('send-button');
|
|
131
|
-
this.UI.sendButton.addEventListener('click', () => this.sendMessage());
|
|
132
|
-
|
|
133
|
-
// Wire up the close button
|
|
134
|
-
this.shadowRoot.querySelector('.close-btn').addEventListener('click', () => {
|
|
135
|
-
//this.closeChat()
|
|
136
|
-
this.closest('joe-ai-chatbox').closeChat();
|
|
137
|
-
});
|
|
138
|
-
// this.ui.assistant_select = this.shadowRoot.querySelector('#assistant-select');
|
|
139
|
-
// this.ui.assistant_select?.addEventListener('change', (e) => {
|
|
140
|
-
// this.selected_assistant_id = e.target.value;
|
|
141
|
-
// });
|
|
142
|
-
//this.selected_assistant_id = this.ui.assistant_select?.value || null;
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
updateMessages(messages){
|
|
146
|
-
messages && (this.messages = messages);
|
|
147
|
-
const chatMessages = this.messages.map(msg => this.renderMessage(msg)).reverse().join('');
|
|
148
|
-
this.UI.content.innerHTML = chatMessages;
|
|
149
|
-
this.UI.content.scrollTop = this.UI.content.scrollHeight;
|
|
150
|
-
}
|
|
151
|
-
async getAllAssistants() {
|
|
152
|
-
const res = await fetch('/API/item/ai_assistant');
|
|
153
|
-
const result = await res.json();
|
|
154
|
-
if (result.error) {
|
|
155
|
-
console.error("Error fetching assistants:", result.error);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
this.allAssistants = {};
|
|
159
|
-
|
|
160
|
-
if (Array.isArray(result.item)) {
|
|
161
|
-
result.item.map(a => {
|
|
162
|
-
if (a._id) {
|
|
163
|
-
this.allAssistants[a._id] = a;
|
|
164
|
-
}
|
|
165
|
-
if (a.openai_id) {
|
|
166
|
-
this.allAssistants[a.openai_id] = a; // Optional dual-key if you prefer
|
|
167
|
-
}
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
async loadConversation() {
|
|
173
|
-
//load conversation and messages into this
|
|
174
|
-
try {
|
|
175
|
-
const res = await fetch(`/API/object/ai_conversation/_id/${this.conversation_id}`);
|
|
176
|
-
const convo = await res.json();
|
|
177
|
-
|
|
178
|
-
if (!convo || convo.error) {
|
|
179
|
-
this.renderError("Conversation not found.");
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
this.conversation = convo;
|
|
183
|
-
this.messages = [];
|
|
184
|
-
|
|
185
|
-
if (convo.thread_id) {
|
|
186
|
-
const resThread = await fetch(`/API/plugin/chatgpt-assistants/getThreadMessages?thread_id=${convo.thread_id}`);
|
|
187
|
-
const threadMessages = await resThread.json();
|
|
188
|
-
this.messages = threadMessages?.messages || [];
|
|
189
|
-
this.thread_id = convo.thread_id;
|
|
190
|
-
this.user = $J.get(convo.user);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// If there is no assistant reply yet, inject a local greeting based on context.
|
|
194
|
-
// This still shows even if we have a single PLATFORM/system card from context.
|
|
195
|
-
const hasAssistantReply = Array.isArray(this.messages) &&
|
|
196
|
-
this.messages.some(m => m.role === 'assistant');
|
|
197
|
-
if (!hasAssistantReply) {
|
|
198
|
-
try {
|
|
199
|
-
const user = this.user || (convo.user && $J.get(convo.user)) || {};
|
|
200
|
-
const contextId = (convo.context_objects && convo.context_objects[0]) || null;
|
|
201
|
-
const ctxObj = contextId ? $J.get(contextId) : null;
|
|
202
|
-
const uname = user.name || 'there';
|
|
203
|
-
let target = 'this conversation';
|
|
204
|
-
if (ctxObj) {
|
|
205
|
-
const label = ctxObj.name || ctxObj.title || ctxObj.label || ctxObj._id;
|
|
206
|
-
target = `${label} (${ctxObj.itemtype || 'item'})`;
|
|
207
|
-
}
|
|
208
|
-
const greeting = `Hi ${uname}, I’m ready to help you with ${target}. What would you like to explore or change?`;
|
|
209
|
-
this.messages.push({
|
|
210
|
-
role: 'assistant',
|
|
211
|
-
content: greeting,
|
|
212
|
-
created_at: Math.floor(Date.now()/1000)
|
|
213
|
-
});
|
|
214
|
-
} catch(_e){}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
return {conversation:convo,messages:this.messages};
|
|
218
|
-
} catch (err) {
|
|
219
|
-
console.error("Chatbox load error:", err);
|
|
220
|
-
this.renderError("Error loading conversation.");
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
getAssistantInfo(id){
|
|
224
|
-
const assistant = this.allAssistants[id];
|
|
225
|
-
if (assistant) {
|
|
226
|
-
return assistant;
|
|
227
|
-
} else {
|
|
228
|
-
console.warn("Assistant not found:", id);
|
|
229
|
-
return null;
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
render() {
|
|
233
|
-
const convoName = this.conversation.name || "Untitled Conversation";
|
|
234
|
-
const convoInfo = this.conversation.info || "";
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
processMessageText(text) {
|
|
246
|
-
|
|
247
|
-
// const replaced = text.replace(
|
|
248
|
-
// /\{\{\{BEGIN_OBJECT:(.*?)\}\}\}[\s\S]*?\{\{\{END_OBJECT:\1\}\}\}/g,
|
|
249
|
-
// (match, cuid) => `<joe-object object_id="${cuid}"></joe-object>`
|
|
250
|
-
// );
|
|
251
|
-
let didReplace = false;
|
|
252
|
-
|
|
253
|
-
const replaced = text.replace(
|
|
254
|
-
/\{\{\{BEGIN_OBJECT:(.*?)\}\}\}[\s\S]*?\{\{\{END_OBJECT:\1\}\}\}/g,
|
|
255
|
-
(match, cuid) => {
|
|
256
|
-
didReplace = true;
|
|
257
|
-
return `<joe-object object_id="${cuid}"></joe-object>`;
|
|
258
|
-
}
|
|
259
|
-
);
|
|
260
|
-
|
|
261
|
-
return {text:replaced, replaced:didReplace};
|
|
262
|
-
}
|
|
263
|
-
renderMessage(msg) {
|
|
264
|
-
const role = msg.role || 'user';
|
|
265
|
-
const classes = `message ${role}`;
|
|
266
|
-
var username = role;
|
|
267
|
-
if(role === 'user'){
|
|
268
|
-
username = this.user.name||'User';
|
|
269
|
-
}
|
|
270
|
-
let contentText = '';
|
|
271
|
-
|
|
272
|
-
if (Array.isArray(msg.content)) {
|
|
273
|
-
// OpenAI style: array of parts
|
|
274
|
-
contentText = msg.content.map(part => {
|
|
275
|
-
if (part.type === 'text' && part.text && part.text.value) {
|
|
276
|
-
return part.text.value;
|
|
277
|
-
}
|
|
278
|
-
return '';
|
|
279
|
-
}).join('\n');
|
|
280
|
-
} else if (typeof msg.content === 'string') {
|
|
281
|
-
contentText = msg.content;
|
|
282
|
-
} else {
|
|
283
|
-
contentText = '[Unsupported message format]';
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
const ctInfo = this.processMessageText(contentText);
|
|
287
|
-
contentText = ctInfo.text;
|
|
288
|
-
if(ctInfo.replaced){
|
|
289
|
-
username = 'platform'
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// Build timestamp
|
|
293
|
-
const createdAt = msg.created_at ? new Date(msg.created_at * 1000) : null; // OpenAI sends timestamps in seconds
|
|
294
|
-
const timestamp = createdAt ? createdAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
|
295
|
-
|
|
296
|
-
return `
|
|
297
|
-
<div class="${classes}">
|
|
298
|
-
<div class="meta">
|
|
299
|
-
<participant-name>${username.toUpperCase()}</participant-name>
|
|
300
|
-
${timestamp ? `<span class="timestamp">${timestamp}</span>` : ''}</div>
|
|
301
|
-
<div class="content">${contentText}</div>
|
|
302
|
-
</div>
|
|
303
|
-
`;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
renderError(message) {
|
|
307
|
-
this.shadowRoot.innerHTML = `<div style="color:red;">${message}</div>
|
|
308
|
-
<div class="close-btn" title="Close Chatbox" >${_joe.SVG.icon.close}</div>`;
|
|
309
|
-
this.shadowRoot.querySelector('.close-btn').addEventListener('click', () => {
|
|
310
|
-
//this.closeChat()
|
|
311
|
-
this.closest('joe-ai-chatbox').closeChat();
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
async getResponse(conversation_id,content,role,assistant_id){
|
|
317
|
-
const response = await fetch('/API/plugin/chatgpt-assistants/addMessage', {
|
|
318
|
-
method: 'POST',
|
|
319
|
-
headers: { 'Content-Type': 'application/json' },
|
|
320
|
-
body: JSON.stringify({
|
|
321
|
-
conversation_id: conversation_id,
|
|
322
|
-
content: content,
|
|
323
|
-
role: role||'system',
|
|
324
|
-
assistant_id: assistant_id||Ai.default_ai?Ai.default_ai.value:null
|
|
325
|
-
})
|
|
326
|
-
}).then(res => res.json());
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
async sendMessage() {//** */
|
|
330
|
-
|
|
331
|
-
const input = this.UI.textarea;
|
|
332
|
-
const message = input.value.trim();
|
|
333
|
-
if (!message) return;
|
|
334
|
-
|
|
335
|
-
input.disabled = true;
|
|
336
|
-
this.UI.sendButton.disabled = true;
|
|
337
|
-
|
|
338
|
-
try {
|
|
339
|
-
const response = await fetch('/API/plugin/chatgpt-assistants/addMessage', {
|
|
340
|
-
method: 'POST',
|
|
341
|
-
headers: { 'Content-Type': 'application/json' },
|
|
342
|
-
body: JSON.stringify({
|
|
343
|
-
conversation_id: this.conversation_id,
|
|
344
|
-
content: message,
|
|
345
|
-
assistant_id: this.selected_assistant_id || (Ai.default_ai ? Ai.default_ai.value : null)
|
|
346
|
-
})
|
|
347
|
-
}).then(res => res.json());
|
|
348
|
-
|
|
349
|
-
if (response.success && (response.run_id || (response.runObj && response.runObj.id))) {
|
|
350
|
-
// Use IDs from server so we don't depend on cache or stale conversation data
|
|
351
|
-
const threadId = response.thread_id || this.conversation.thread_id;
|
|
352
|
-
this.conversation.thread_id = threadId;
|
|
353
|
-
this.currentRunId = response.run_id || (response.runObj && response.runObj.id);
|
|
354
|
-
|
|
355
|
-
// Reload conversation (name, info, etc.), but preserve the known thread_id
|
|
356
|
-
await this.loadConversation();
|
|
357
|
-
if (!this.conversation.thread_id && threadId) {
|
|
358
|
-
this.conversation.thread_id = threadId;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
this.UI.content.update(); // update messages
|
|
362
|
-
this.startPolling(); // 🌸 start watching for assistant reply!
|
|
363
|
-
input.value = '';
|
|
364
|
-
} else {
|
|
365
|
-
alert((response.error||'Failed to send message.')+' - '+response.message);
|
|
366
|
-
}
|
|
367
|
-
} catch (err) {
|
|
368
|
-
console.error('Send message error:', err);
|
|
369
|
-
alert('Error sending message.');
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
// input.disabled = false;
|
|
374
|
-
// this.shadowRoot.getElementById('send-button').disabled = false;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
startPolling() {
|
|
378
|
-
if (this.pollingInterval) return; // Already polling
|
|
379
|
-
|
|
380
|
-
// Insert thinking message
|
|
381
|
-
this.showThinkingMessage();
|
|
382
|
-
|
|
383
|
-
// Baseline: timestamp of the latest assistant message we have so far
|
|
384
|
-
const lastAssistantTs = (this.messages || [])
|
|
385
|
-
.filter(m => m && m.role === 'assistant' && m.created_at)
|
|
386
|
-
.reduce((max, m) => Math.max(max, m.created_at), 0);
|
|
387
|
-
|
|
388
|
-
let attempts = 0;
|
|
389
|
-
this.pollingInterval = setInterval(async () => {
|
|
390
|
-
attempts++;
|
|
391
|
-
try {
|
|
392
|
-
const resThread = await fetch(`/API/plugin/chatgpt-assistants/getThreadMessages?thread_id=${this.conversation.thread_id}&polling=true`);
|
|
393
|
-
const threadMessages = await resThread.json();
|
|
394
|
-
|
|
395
|
-
if (threadMessages?.messages) {
|
|
396
|
-
const msgs = threadMessages.messages;
|
|
397
|
-
const latestAssistantTs = msgs
|
|
398
|
-
.filter(m => m && m.role === 'assistant' && m.created_at)
|
|
399
|
-
.reduce((max, m) => Math.max(max, m.created_at), 0);
|
|
400
|
-
|
|
401
|
-
// When we see a newer assistant message than we had before, treat it as the reply.
|
|
402
|
-
if (latestAssistantTs && latestAssistantTs > lastAssistantTs) {
|
|
403
|
-
this.messages = msgs;
|
|
404
|
-
this.UI.content.update(msgs);
|
|
405
|
-
|
|
406
|
-
clearInterval(this.pollingInterval);
|
|
407
|
-
this.pollingInterval = null;
|
|
408
|
-
this.hideThinkingMessage();
|
|
409
|
-
this.UI.textarea.disabled = false;
|
|
410
|
-
this.UI.sendButton.disabled = false;
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
if (attempts > 60) { // ~2 minutes
|
|
416
|
-
console.warn('Thread polling timeout for assistant reply');
|
|
417
|
-
clearInterval(this.pollingInterval);
|
|
418
|
-
this.pollingInterval = null;
|
|
419
|
-
this.hideThinkingMessage();
|
|
420
|
-
this.UI.textarea.disabled = false;
|
|
421
|
-
this.UI.sendButton.disabled = false;
|
|
422
|
-
alert('Timed out waiting for assistant response.');
|
|
423
|
-
}
|
|
424
|
-
} catch (err) {
|
|
425
|
-
console.error('Polling error (thread messages):', err);
|
|
426
|
-
clearInterval(this.pollingInterval);
|
|
427
|
-
this.pollingInterval = null;
|
|
428
|
-
this.hideThinkingMessage();
|
|
429
|
-
this.UI.textarea.disabled = false;
|
|
430
|
-
this.UI.sendButton.disabled = false;
|
|
431
|
-
alert('Error while checking assistant response.');
|
|
432
|
-
}
|
|
433
|
-
}, 2000);
|
|
434
|
-
}
|
|
435
|
-
showThinkingMessage() {
|
|
436
|
-
const messagesDiv = this.UI.content;
|
|
437
|
-
if (!messagesDiv) return;
|
|
438
|
-
|
|
439
|
-
// Pull assistant thinking text
|
|
440
|
-
const assistant = this.getAssistantInfo(this.selected_assistant_id);
|
|
441
|
-
const thinkingText = assistant?.assistant_thinking_text || 'Assistant is thinking...';
|
|
442
|
-
|
|
443
|
-
const div = document.createElement('div');
|
|
444
|
-
div.className = 'thinking-message';
|
|
445
|
-
div.textContent = thinkingText;
|
|
446
|
-
div.id = 'thinking-message';
|
|
447
|
-
messagesDiv.appendChild(div);
|
|
448
|
-
|
|
449
|
-
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
hideThinkingMessage() {
|
|
453
|
-
const existing = this.shadowRoot.querySelector('#thinking-message');
|
|
454
|
-
if (existing) existing.remove();
|
|
455
|
-
}
|
|
456
|
-
closeChat() {
|
|
457
|
-
// Remove the element
|
|
458
|
-
this.remove();
|
|
459
|
-
|
|
460
|
-
// Clean up from open chat registry if possible
|
|
461
|
-
if (_joe && _joe.Ai && _joe.Ai._openChats) {
|
|
462
|
-
delete _joe.Ai._openChats[this.conversation_id];
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
customElements.define('joe-ai-chatbox', JoeAIChatbox);
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
class JoeObject extends HTMLElement {
|
|
471
|
-
constructor() {
|
|
472
|
-
super();
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
this.object_id = this.getAttribute('object_id');
|
|
476
|
-
this.object = $J.get(this.object_id);
|
|
477
|
-
|
|
478
|
-
}
|
|
479
|
-
connectedCallback() {
|
|
480
|
-
const id = this.getAttribute('object_id');
|
|
481
|
-
var sTemp = $J.schema('business')?.listView?.title||false;
|
|
482
|
-
this.innerHTML = (sTemp)?JOE.propAsFuncOrValue(sTemp,this.object) :`<jo-title>${this.object.name}</jo-title>
|
|
483
|
-
<jo-subtitle>${this.object.info} - ${this.object._id}</jo-subtitle>`;
|
|
484
|
-
this.addEventListener('click', () => {
|
|
485
|
-
// Handle click event here, e.g., open the object in a new tab or show details
|
|
486
|
-
goJoe(_joe.search(this.object._id)[0],{schema:this.object.itemtype})
|
|
487
|
-
//window.open(`/object/${this.object_id}`, '_blank');
|
|
488
|
-
});
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
}
|
|
492
|
-
customElements.define('joe-object', JoeObject);
|
|
493
|
-
|
|
494
|
-
// ---------- Joe AI Widget: embeddable chat for any site ----------
|
|
495
|
-
class JoeAIWidget extends HTMLElement {
|
|
496
|
-
constructor() {
|
|
497
|
-
super();
|
|
498
|
-
this.attachShadow({ mode: 'open' });
|
|
499
|
-
this.endpoint = this.getAttribute('endpoint') || ''; // base URL to JOE; default same origin
|
|
500
|
-
this.conversation_id = this.getAttribute('conversation_id') || null;
|
|
501
|
-
this.assistant_id = this.getAttribute('assistant_id') || null;
|
|
502
|
-
this.ai_assistant_id = this.getAttribute('ai_assistant_id') || null;
|
|
503
|
-
this.user_id = this.getAttribute('user_id') || null;
|
|
504
|
-
this.model = this.getAttribute('model') || null;
|
|
505
|
-
this.assistant_color = this.getAttribute('assistant_color') || null;
|
|
506
|
-
this.user_color = this.getAttribute('user_color') || null;
|
|
507
|
-
// Optional persistence key so widgets inside dynamic layouts (like capp cards)
|
|
508
|
-
// can restore their conversation after re-render or resize.
|
|
509
|
-
this.persist_key =
|
|
510
|
-
this.getAttribute('persist_key') ||
|
|
511
|
-
(this.getAttribute('source') ? ('joe-ai-widget:' + this.getAttribute('source')) : null);
|
|
512
|
-
this.messages = [];
|
|
513
|
-
this._ui = {};
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
connectedCallback() {
|
|
517
|
-
this.renderShell();
|
|
518
|
-
// If we don't have an explicit conversation_id but a persisted one exists,
|
|
519
|
-
// restore it before deciding what to load.
|
|
520
|
-
this.restoreFromStorageIfNeeded();
|
|
521
|
-
// Lazy conversation creation:
|
|
522
|
-
// - If a conversation_id is provided, load its history.
|
|
523
|
-
// - Otherwise, do NOT create a conversation until the user actually
|
|
524
|
-
// sends a message. `sendMessage` will call startConversation() on
|
|
525
|
-
// demand when needed.
|
|
526
|
-
if (this.conversation_id) {
|
|
527
|
-
this.loadHistory();
|
|
528
|
-
} else {
|
|
529
|
-
this.setStatus('online');
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
get apiBase() {
|
|
534
|
-
return this.endpoint || '';
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
persistState() {
|
|
538
|
-
if (!this.persist_key || typeof window === 'undefined' || !window.localStorage) return;
|
|
539
|
-
try {
|
|
540
|
-
const payload = {
|
|
541
|
-
conversation_id: this.conversation_id,
|
|
542
|
-
assistant_id: this.assistant_id,
|
|
543
|
-
model: this.model,
|
|
544
|
-
assistant_color: this.assistant_color
|
|
545
|
-
};
|
|
546
|
-
window.localStorage.setItem(this.persist_key, JSON.stringify(payload));
|
|
547
|
-
} catch (_e) { /* ignore storage errors */ }
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
restoreFromStorageIfNeeded() {
|
|
551
|
-
if (this.conversation_id || !this.persist_key || typeof window === 'undefined' || !window.localStorage) return;
|
|
552
|
-
try {
|
|
553
|
-
const raw = window.localStorage.getItem(this.persist_key);
|
|
554
|
-
if (!raw) return;
|
|
555
|
-
const data = JSON.parse(raw);
|
|
556
|
-
if (!data || !data.conversation_id) return;
|
|
557
|
-
this.conversation_id = data.conversation_id;
|
|
558
|
-
this.setAttribute('conversation_id', this.conversation_id);
|
|
559
|
-
this.assistant_id = data.assistant_id || this.assistant_id;
|
|
560
|
-
this.model = data.model || this.model;
|
|
561
|
-
this.assistant_color = data.assistant_color || this.assistant_color;
|
|
562
|
-
} catch (_e) { /* ignore */ }
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
renderShell() {
|
|
566
|
-
const style = document.createElement('style');
|
|
567
|
-
style.textContent = `
|
|
568
|
-
:host {
|
|
569
|
-
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
570
|
-
display: block;
|
|
571
|
-
/* Allow the widget to grow/shrink with its container (cards, sidebars, etc.) */
|
|
572
|
-
width: 100%;
|
|
573
|
-
height: 100%;
|
|
574
|
-
max-width: none;
|
|
575
|
-
border: 1px solid #ddd;
|
|
576
|
-
border-radius: 8px;
|
|
577
|
-
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
|
|
578
|
-
overflow: hidden;
|
|
579
|
-
background: #fff;
|
|
580
|
-
}
|
|
581
|
-
.wrapper {
|
|
582
|
-
display: flex;
|
|
583
|
-
flex-direction: column;
|
|
584
|
-
height: 100%;
|
|
585
|
-
}
|
|
586
|
-
.header {
|
|
587
|
-
padding: 8px 12px;
|
|
588
|
-
background: #1f2933;
|
|
589
|
-
color: #f9fafb;
|
|
590
|
-
font-size: 13px;
|
|
591
|
-
display: flex;
|
|
592
|
-
align-items: center;
|
|
593
|
-
justify-content: space-between;
|
|
594
|
-
}
|
|
595
|
-
.title {
|
|
596
|
-
font-weight: 600;
|
|
597
|
-
}
|
|
598
|
-
.status {
|
|
599
|
-
font-size: 11px;
|
|
600
|
-
opacity: 0.8;
|
|
601
|
-
}
|
|
602
|
-
.messages {
|
|
603
|
-
padding: 10px;
|
|
604
|
-
/* Fill remaining space between header and footer */
|
|
605
|
-
flex: 1 1 auto;
|
|
606
|
-
min-height: 0;
|
|
607
|
-
overflow-y: auto;
|
|
608
|
-
background: #f5f7fa;
|
|
609
|
-
font-size: 13px;
|
|
610
|
-
}
|
|
611
|
-
.msg {
|
|
612
|
-
margin-bottom: 8px;
|
|
613
|
-
max-width: 90%;
|
|
614
|
-
clear: both;
|
|
615
|
-
}
|
|
616
|
-
.msg.user {
|
|
617
|
-
text-align: right;
|
|
618
|
-
margin-left: auto;
|
|
619
|
-
}
|
|
620
|
-
.bubble {
|
|
621
|
-
display: inline-block;
|
|
622
|
-
padding: 6px 8px;
|
|
623
|
-
border-radius: 5px;
|
|
624
|
-
line-height: 1.4;
|
|
625
|
-
max-width: 100%;
|
|
626
|
-
overflow-x: auto;
|
|
627
|
-
}
|
|
628
|
-
.user .bubble {
|
|
629
|
-
background: var(--joe-ai-user-bg, #2563eb);
|
|
630
|
-
color: #fff;
|
|
631
|
-
}
|
|
632
|
-
.assistant .bubble {
|
|
633
|
-
background: var(--joe-ai-assistant-bg, #e5e7eb);
|
|
634
|
-
color: #111827;
|
|
635
|
-
}
|
|
636
|
-
.msg.assistant.tools-used .bubble {
|
|
637
|
-
background: #fef3c7;
|
|
638
|
-
color: #92400e;
|
|
639
|
-
font-size: 11px;
|
|
640
|
-
}
|
|
641
|
-
.footer {
|
|
642
|
-
border-top: 1px solid #e5e7eb;
|
|
643
|
-
padding: 6px;
|
|
644
|
-
display: flex;
|
|
645
|
-
gap: 6px;
|
|
646
|
-
align-items: center;
|
|
647
|
-
}
|
|
648
|
-
textarea {
|
|
649
|
-
flex: 1;
|
|
650
|
-
resize: none;
|
|
651
|
-
border-radius: 6px;
|
|
652
|
-
border: 1px solid #d1d5db;
|
|
653
|
-
padding: 6px 8px;
|
|
654
|
-
font-size: 13px;
|
|
655
|
-
min-height: 34px;
|
|
656
|
-
max-height: 80px;
|
|
657
|
-
}
|
|
658
|
-
button {
|
|
659
|
-
border-radius: 6px;
|
|
660
|
-
border: none;
|
|
661
|
-
background: #2563eb;
|
|
662
|
-
color: #fff;
|
|
663
|
-
padding: 6px 10px;
|
|
664
|
-
font-size: 13px;
|
|
665
|
-
cursor: pointer;
|
|
666
|
-
white-space: nowrap;
|
|
667
|
-
}
|
|
668
|
-
button:disabled {
|
|
669
|
-
opacity: 0.6;
|
|
670
|
-
cursor: default;
|
|
671
|
-
}
|
|
672
|
-
`;
|
|
673
|
-
|
|
674
|
-
const wrapper = document.createElement('div');
|
|
675
|
-
wrapper.className = 'wrapper';
|
|
676
|
-
wrapper.innerHTML = `
|
|
677
|
-
<div class="header">
|
|
678
|
-
<div class="title">${this.getAttribute('title') || 'AI Assistant'}</div>
|
|
679
|
-
<div class="status" id="status">connecting…</div>
|
|
680
|
-
</div>
|
|
681
|
-
<div class="messages" id="messages"></div>
|
|
682
|
-
<div class="footer">
|
|
683
|
-
<textarea id="input" placeholder="${this.getAttribute('placeholder') || 'Ask me anything…'}"></textarea>
|
|
684
|
-
<button id="send">Send</button>
|
|
685
|
-
</div>
|
|
686
|
-
`;
|
|
687
|
-
|
|
688
|
-
this.shadowRoot.innerHTML = '';
|
|
689
|
-
this.shadowRoot.appendChild(style);
|
|
690
|
-
this.shadowRoot.appendChild(wrapper);
|
|
691
|
-
|
|
692
|
-
this._ui.messages = this.shadowRoot.getElementById('messages');
|
|
693
|
-
this._ui.status = this.shadowRoot.getElementById('status');
|
|
694
|
-
this._ui.input = this.shadowRoot.getElementById('input');
|
|
695
|
-
this._ui.send = this.shadowRoot.getElementById('send');
|
|
696
|
-
|
|
697
|
-
this._ui.send.addEventListener('click', () => this.sendMessage());
|
|
698
|
-
this._ui.input.addEventListener('keydown', (e) => {
|
|
699
|
-
if (e.key === 'Enter' && !e.shiftKey) {
|
|
700
|
-
e.preventDefault();
|
|
701
|
-
this.sendMessage();
|
|
702
|
-
}
|
|
703
|
-
});
|
|
704
|
-
this.applyThemeColors();
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
applyThemeColors() {
|
|
708
|
-
if (!this.shadowRoot || !this.shadowRoot.host) return;
|
|
709
|
-
const host = this.shadowRoot.host;
|
|
710
|
-
if (this.assistant_color) {
|
|
711
|
-
host.style.setProperty('--joe-ai-assistant-bg', this.assistant_color);
|
|
712
|
-
}
|
|
713
|
-
if (this.user_color) {
|
|
714
|
-
host.style.setProperty('--joe-ai-user-bg', this.user_color);
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
setStatus(text) {
|
|
719
|
-
if (!this._ui.status) return;
|
|
720
|
-
const cid = this.conversation_id || '';
|
|
721
|
-
// Show conversation id when available, e.g. "cuid123 - online"
|
|
722
|
-
this._ui.status.textContent = cid ? (cid + ' - ' + text) : text;
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
renderMessages() {
|
|
726
|
-
if (!this._ui.messages) return;
|
|
727
|
-
this._ui.messages.innerHTML = (this.messages || []).map(m => {
|
|
728
|
-
const role = m.role || 'assistant';
|
|
729
|
-
const extra = m.meta === 'tools_used' ? ' tools-used' : '';
|
|
730
|
-
const html = renderMarkdownSafe(m.content || '');
|
|
731
|
-
return `<div class="msg ${role}${extra}"><div class="bubble">${html}</div></div>`;
|
|
732
|
-
}).join('');
|
|
733
|
-
this._ui.messages.scrollTop = this._ui.messages.scrollHeight;
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
// Resolve the logical user for this widget instance.
|
|
737
|
-
// Priority:
|
|
738
|
-
// 1) Explicit user_id attribute on the element (fetch /API/item/user/_id/:id)
|
|
739
|
-
// 2) Page-level window._aiWidgetUser (used by ai-widget-test.html)
|
|
740
|
-
// 3) null (caller can still force a user_color theme)
|
|
741
|
-
async resolveUser() {
|
|
742
|
-
if (this._resolvedUser) {
|
|
743
|
-
return this._resolvedUser;
|
|
744
|
-
}
|
|
745
|
-
// 1) If the hosting page passed a user_id attribute, look up that user via JOE.
|
|
746
|
-
if (this.user_id) {
|
|
747
|
-
try {
|
|
748
|
-
const res = await fetch(
|
|
749
|
-
this.apiBase + '/API/item/user/_id/' + encodeURIComponent(this.user_id),
|
|
750
|
-
{ credentials: 'include' }
|
|
751
|
-
);
|
|
752
|
-
const data = await res.json();
|
|
753
|
-
const u = (data && data.item) || null;
|
|
754
|
-
if (u && u._id) {
|
|
755
|
-
this._resolvedUser = u;
|
|
756
|
-
return u;
|
|
757
|
-
}
|
|
758
|
-
} catch (e) {
|
|
759
|
-
console.error('JoeAIWidget.resolveUser: failed to load user by id', this.user_id, e);
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
// 2) Fall back to a page-global user (ai-widget-test.html populates this).
|
|
763
|
-
if (typeof window !== 'undefined' && window._aiWidgetUser) {
|
|
764
|
-
this._resolvedUser = window._aiWidgetUser;
|
|
765
|
-
return this._resolvedUser;
|
|
766
|
-
}
|
|
767
|
-
return null;
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
async startConversation() {
|
|
771
|
-
try {
|
|
772
|
-
this.setStatus('connecting…');
|
|
773
|
-
const payload = {
|
|
774
|
-
model: this.model || undefined,
|
|
775
|
-
ai_assistant_id: this.getAttribute('ai_assistant_id') || undefined,
|
|
776
|
-
source: this.getAttribute('source') || 'widget'
|
|
777
|
-
};
|
|
778
|
-
// Resolve the effective user for this widget and pass id/name/color
|
|
779
|
-
// explicitly to the server. This works for:
|
|
780
|
-
// - ai-widget-test.html (which sets window._aiWidgetUser)
|
|
781
|
-
// - JOE pages or external sites that pass a user_id attribute
|
|
782
|
-
try {
|
|
783
|
-
const globalUser = await this.resolveUser();
|
|
784
|
-
if (globalUser) {
|
|
785
|
-
payload.user_id = globalUser._id;
|
|
786
|
-
payload.user_name = globalUser.fullname || globalUser.name;
|
|
787
|
-
if (globalUser.color) { payload.user_color = globalUser.color; }
|
|
788
|
-
} else if (this.user_color) {
|
|
789
|
-
payload.user_color = this.user_color;
|
|
790
|
-
}
|
|
791
|
-
} catch (_e) { /* ignore */ }
|
|
792
|
-
const resp = await fetch(this.apiBase + '/API/plugin/chatgpt/widgetStart', {
|
|
793
|
-
method: 'POST',
|
|
794
|
-
headers: { 'Content-Type': 'application/json' },
|
|
795
|
-
body: JSON.stringify(payload)
|
|
796
|
-
}).then(r => r.json());
|
|
797
|
-
|
|
798
|
-
if (!resp || resp.success !== true) {
|
|
799
|
-
const msg = (resp && (resp.error || resp.message)) || 'Failed to start conversation';
|
|
800
|
-
this.setStatus('error: ' + msg);
|
|
801
|
-
this.messages.push({
|
|
802
|
-
role:'assistant',
|
|
803
|
-
content:'[Error starting conversation: '+msg+']',
|
|
804
|
-
created_at:new Date().toISOString()
|
|
805
|
-
});
|
|
806
|
-
this.renderMessages();
|
|
807
|
-
console.error('widgetStart error', { payload, response: resp });
|
|
808
|
-
return;
|
|
809
|
-
}
|
|
810
|
-
this.conversation_id = resp.conversation_id;
|
|
811
|
-
this.setAttribute('conversation_id', this.conversation_id);
|
|
812
|
-
this.assistant_id = resp.assistant_id || this.assistant_id;
|
|
813
|
-
this.model = resp.model || this.model;
|
|
814
|
-
this.assistant_color = resp.assistant_color || this.assistant_color;
|
|
815
|
-
this.applyThemeColors();
|
|
816
|
-
this.messages = [];
|
|
817
|
-
this.renderMessages();
|
|
818
|
-
this.setStatus('online');
|
|
819
|
-
this.persistState();
|
|
820
|
-
} catch (e) {
|
|
821
|
-
console.error('widgetStart exception', e);
|
|
822
|
-
this.setStatus('error');
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
async loadHistory() {
|
|
827
|
-
try {
|
|
828
|
-
this.setStatus('loading…');
|
|
829
|
-
const resp = await fetch(
|
|
830
|
-
this.apiBase + '/API/plugin/chatgpt/widgetHistory?conversation_id=' +
|
|
831
|
-
encodeURIComponent(this.conversation_id)
|
|
832
|
-
).then(r => r.json());
|
|
833
|
-
if (!resp || resp.success !== true) {
|
|
834
|
-
console.warn('widgetHistory non-success response', resp);
|
|
835
|
-
this.setStatus('online');
|
|
836
|
-
return;
|
|
837
|
-
}
|
|
838
|
-
this.assistant_id = resp.assistant_id || this.assistant_id;
|
|
839
|
-
this.model = resp.model || this.model;
|
|
840
|
-
this.assistant_color = resp.assistant_color || this.assistant_color;
|
|
841
|
-
this.applyThemeColors();
|
|
842
|
-
this.messages = resp.messages || [];
|
|
843
|
-
this.renderMessages();
|
|
844
|
-
this.setStatus('online');
|
|
845
|
-
this.persistState();
|
|
846
|
-
} catch (e) {
|
|
847
|
-
console.error('widgetHistory exception', e);
|
|
848
|
-
this.setStatus('online');
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
async sendMessage() {
|
|
853
|
-
const input = this._ui.input;
|
|
854
|
-
if (!input) return;
|
|
855
|
-
const text = input.value.trim();
|
|
856
|
-
if (!text) return;
|
|
857
|
-
if (!this.conversation_id) {
|
|
858
|
-
await this.startConversation();
|
|
859
|
-
if (!this.conversation_id) return;
|
|
860
|
-
}
|
|
861
|
-
input.value = '';
|
|
862
|
-
this._ui.send.disabled = true;
|
|
863
|
-
|
|
864
|
-
const userMsg = { role: 'user', content: text, created_at: new Date().toISOString() };
|
|
865
|
-
this.messages.push(userMsg);
|
|
866
|
-
this.renderMessages();
|
|
867
|
-
this.setStatus('thinking…');
|
|
868
|
-
|
|
869
|
-
try {
|
|
870
|
-
const payload = {
|
|
871
|
-
conversation_id: this.conversation_id,
|
|
872
|
-
content: text,
|
|
873
|
-
role: 'user',
|
|
874
|
-
assistant_id: this.assistant_id || undefined,
|
|
875
|
-
model: this.model || undefined
|
|
876
|
-
};
|
|
877
|
-
try {
|
|
878
|
-
const globalUser = await this.resolveUser();
|
|
879
|
-
if (globalUser) {
|
|
880
|
-
payload.user_id = globalUser._id;
|
|
881
|
-
payload.user_name = globalUser.fullname || globalUser.name;
|
|
882
|
-
if (globalUser.color) { payload.user_color = globalUser.color; }
|
|
883
|
-
} else if (this.user_color) {
|
|
884
|
-
payload.user_color = this.user_color;
|
|
885
|
-
}
|
|
886
|
-
} catch (_e) { /* ignore */ }
|
|
887
|
-
const resp = await fetch(this.apiBase + '/API/plugin/chatgpt/widgetMessage', {
|
|
888
|
-
method: 'POST',
|
|
889
|
-
headers: { 'Content-Type': 'application/json' },
|
|
890
|
-
body: JSON.stringify(payload)
|
|
891
|
-
}).then(r => r.json());
|
|
892
|
-
|
|
893
|
-
if (!resp || resp.success !== true) {
|
|
894
|
-
const msg = (resp && (resp.error || resp.message)) || 'Failed to send message';
|
|
895
|
-
console.error('widgetMessage error', { payload, response: resp });
|
|
896
|
-
this.messages.push({ role:'assistant', content:'[Error: '+msg+']', created_at:new Date().toISOString() });
|
|
897
|
-
this.renderMessages();
|
|
898
|
-
this.setStatus('error: ' + msg);
|
|
899
|
-
} else {
|
|
900
|
-
this.assistant_id = resp.assistant_id || this.assistant_id;
|
|
901
|
-
this.model = resp.model || this.model;
|
|
902
|
-
this.assistant_color = resp.assistant_color || this.assistant_color;
|
|
903
|
-
this.applyThemeColors();
|
|
904
|
-
this.messages = resp.messages || this.messages;
|
|
905
|
-
this.renderMessages();
|
|
906
|
-
this.setStatus('online');
|
|
907
|
-
this.persistState();
|
|
908
|
-
}
|
|
909
|
-
} catch (e) {
|
|
910
|
-
console.error('widgetMessage exception', e);
|
|
911
|
-
this.setStatus('error');
|
|
912
|
-
} finally {
|
|
913
|
-
this._ui.send.disabled = false;
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
if (!customElements.get('joe-ai-widget')) {
|
|
919
|
-
customElements.define('joe-ai-widget', JoeAIWidget);
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
// ---------- Assistant picker: small selector component for joe-ai-widget ----------
|
|
923
|
-
class JoeAIAssistantPicker extends HTMLElement {
|
|
924
|
-
constructor() {
|
|
925
|
-
super();
|
|
926
|
-
this.attachShadow({ mode: 'open' });
|
|
927
|
-
this._ui = {};
|
|
928
|
-
this._assistants = [];
|
|
929
|
-
this._defaultId = null;
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
connectedCallback() {
|
|
933
|
-
this.renderShell();
|
|
934
|
-
this.init();
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
get widget() {
|
|
938
|
-
const targetId = this.getAttribute('for_widget');
|
|
939
|
-
if (targetId) {
|
|
940
|
-
return document.getElementById(targetId);
|
|
941
|
-
}
|
|
942
|
-
// Fallback: nearest joe-ai-widget in the same card/container
|
|
943
|
-
return this.closest('capp-card,div')?.querySelector('joe-ai-widget') || null;
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
renderShell() {
|
|
947
|
-
const style = document.createElement('style');
|
|
948
|
-
style.textContent = `
|
|
949
|
-
:host {
|
|
950
|
-
display: block;
|
|
951
|
-
margin-bottom: 6px;
|
|
952
|
-
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
953
|
-
font-size: 12px;
|
|
954
|
-
}
|
|
955
|
-
.row {
|
|
956
|
-
display: flex;
|
|
957
|
-
align-items: center;
|
|
958
|
-
justify-content: space-between;
|
|
959
|
-
gap: 6px;
|
|
960
|
-
}
|
|
961
|
-
label {
|
|
962
|
-
color: #374151;
|
|
963
|
-
}
|
|
964
|
-
select {
|
|
965
|
-
margin-left: 4px;
|
|
966
|
-
padding: 2px 4px;
|
|
967
|
-
font-size: 12px;
|
|
968
|
-
}
|
|
969
|
-
.hint {
|
|
970
|
-
font-size: 11px;
|
|
971
|
-
color: #6b7280;
|
|
972
|
-
margin-left: 4px;
|
|
973
|
-
}
|
|
974
|
-
`;
|
|
975
|
-
const wrapper = document.createElement('div');
|
|
976
|
-
wrapper.className = 'row';
|
|
977
|
-
wrapper.innerHTML = `
|
|
978
|
-
<div>
|
|
979
|
-
<label>Assistant:</label>
|
|
980
|
-
<select id="assistant-select"></select>
|
|
981
|
-
<span id="assistant-hint" class="hint"></span>
|
|
982
|
-
</div>
|
|
983
|
-
<div>
|
|
984
|
-
<button id="new-convo" style="font-size:11px;padding:2px 8px;">New conversation</button>
|
|
985
|
-
</div>
|
|
986
|
-
`;
|
|
987
|
-
this.shadowRoot.innerHTML = '';
|
|
988
|
-
this.shadowRoot.appendChild(style);
|
|
989
|
-
this.shadowRoot.appendChild(wrapper);
|
|
990
|
-
this._ui.select = this.shadowRoot.getElementById('assistant-select');
|
|
991
|
-
this._ui.hint = this.shadowRoot.getElementById('assistant-hint');
|
|
992
|
-
this._ui.newConvo = this.shadowRoot.getElementById('new-convo');
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
async init() {
|
|
996
|
-
const select = this._ui.select;
|
|
997
|
-
const hint = this._ui.hint;
|
|
998
|
-
const widget = this.widget;
|
|
999
|
-
if (!select || !widget) return;
|
|
1000
|
-
|
|
1001
|
-
try {
|
|
1002
|
-
const [assistantsResp, settingsResp] = await Promise.all([
|
|
1003
|
-
fetch('/API/item/ai_assistant', { credentials: 'include' }).then(r => r.json()),
|
|
1004
|
-
fetch('/API/item/setting', { credentials: 'include' }).then(r => r.json())
|
|
1005
|
-
]);
|
|
1006
|
-
this._assistants = Array.isArray(assistantsResp.item) ? assistantsResp.item : [];
|
|
1007
|
-
const settings = Array.isArray(settingsResp.item) ? settingsResp.item : [];
|
|
1008
|
-
const def = settings.find(s => s && s.name === 'DEFAULT_AI_ASSISTANT');
|
|
1009
|
-
this._defaultId = def && def.value;
|
|
1010
|
-
} catch (e) {
|
|
1011
|
-
console.error('[joe-ai] AssistantPicker init error', e);
|
|
1012
|
-
this._assistants = [];
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
select.innerHTML = '';
|
|
1016
|
-
const noneOption = document.createElement('option');
|
|
1017
|
-
noneOption.value = '';
|
|
1018
|
-
noneOption.textContent = 'None (model only)';
|
|
1019
|
-
select.appendChild(noneOption);
|
|
1020
|
-
|
|
1021
|
-
this._assistants.forEach(a => {
|
|
1022
|
-
const opt = document.createElement('option');
|
|
1023
|
-
opt.value = a._id;
|
|
1024
|
-
opt.textContent = a.name || a.title || a._id;
|
|
1025
|
-
if (a._id === this._defaultId) {
|
|
1026
|
-
opt.textContent += ' (default)';
|
|
1027
|
-
}
|
|
1028
|
-
select.appendChild(opt);
|
|
1029
|
-
});
|
|
1030
|
-
|
|
1031
|
-
let initialId = '';
|
|
1032
|
-
if (widget.getAttribute('ai_assistant_id')) {
|
|
1033
|
-
initialId = widget.getAttribute('ai_assistant_id');
|
|
1034
|
-
hint && (hint.textContent = 'Using assistant from widget attribute.');
|
|
1035
|
-
} else if (this._defaultId && this._assistants.some(a => a && a._id === this._defaultId)) {
|
|
1036
|
-
initialId = this._defaultId;
|
|
1037
|
-
hint && (hint.textContent = 'Using DEFAULT_AI_ASSISTANT.');
|
|
1038
|
-
} else {
|
|
1039
|
-
hint && (hint.textContent = this._assistants.length
|
|
1040
|
-
? 'No default; using model only.'
|
|
1041
|
-
: 'No assistants defined; using model only.');
|
|
1042
|
-
}
|
|
1043
|
-
select.value = initialId;
|
|
1044
|
-
this.applyAssistantToWidget(widget, initialId);
|
|
1045
|
-
|
|
1046
|
-
select.addEventListener('change', () => {
|
|
1047
|
-
this.applyAssistantToWidget(widget, select.value);
|
|
1048
|
-
});
|
|
1049
|
-
|
|
1050
|
-
if (this._ui.newConvo) {
|
|
1051
|
-
this._ui.newConvo.addEventListener('click', () => {
|
|
1052
|
-
this.startNewConversation(widget);
|
|
1053
|
-
});
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
startNewConversation(widget) {
|
|
1058
|
-
try {
|
|
1059
|
-
if (!widget) return;
|
|
1060
|
-
// Clear persisted state and conversation id so a fresh widgetStart will happen
|
|
1061
|
-
if (widget.persist_key && typeof window !== 'undefined' && window.localStorage) {
|
|
1062
|
-
try { window.localStorage.removeItem(widget.persist_key); } catch (_e) { /* ignore */ }
|
|
1063
|
-
}
|
|
1064
|
-
widget.removeAttribute('conversation_id');
|
|
1065
|
-
widget.conversation_id = null;
|
|
1066
|
-
if (Array.isArray(widget.messages)) {
|
|
1067
|
-
widget.messages = [];
|
|
1068
|
-
}
|
|
1069
|
-
if (typeof widget.renderMessages === 'function') {
|
|
1070
|
-
widget.renderMessages();
|
|
1071
|
-
}
|
|
1072
|
-
if (typeof widget.setStatus === 'function') {
|
|
1073
|
-
widget.setStatus('online');
|
|
1074
|
-
}
|
|
1075
|
-
} catch (e) {
|
|
1076
|
-
console.error('[joe-ai] error starting new conversation from picker', e);
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
applyAssistantToWidget(widget, assistantId) {
|
|
1081
|
-
try {
|
|
1082
|
-
if (!widget) return;
|
|
1083
|
-
const val = assistantId || '';
|
|
1084
|
-
if (val) {
|
|
1085
|
-
widget.setAttribute('ai_assistant_id', val);
|
|
1086
|
-
} else {
|
|
1087
|
-
widget.removeAttribute('ai_assistant_id');
|
|
1088
|
-
}
|
|
1089
|
-
// Reset conversation and clear persisted state so a new chat starts
|
|
1090
|
-
if (widget.persist_key && typeof window !== 'undefined' && window.localStorage) {
|
|
1091
|
-
try { window.localStorage.removeItem(widget.persist_key); } catch (_e) { /* ignore */ }
|
|
1092
|
-
}
|
|
1093
|
-
widget.removeAttribute('conversation_id');
|
|
1094
|
-
widget.conversation_id = null;
|
|
1095
|
-
if (typeof widget.setStatus === 'function') {
|
|
1096
|
-
widget.setStatus('online');
|
|
1097
|
-
}
|
|
1098
|
-
} catch (e) {
|
|
1099
|
-
console.error('[joe-ai] error applying assistant selection', e);
|
|
1100
|
-
}
|
|
1101
|
-
}
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
if (!customElements.get('joe-ai-assistant-picker')) {
|
|
1105
|
-
customElements.define('joe-ai-assistant-picker', JoeAIAssistantPicker);
|
|
1106
|
-
}
|
|
1107
|
-
|
|
1108
|
-
// ---------- Conversation list: clickable ai_widget_conversation list ----------
|
|
1109
|
-
class JoeAIConversationList extends HTMLElement {
|
|
1110
|
-
constructor() {
|
|
1111
|
-
super();
|
|
1112
|
-
this.attachShadow({ mode: 'open' });
|
|
1113
|
-
this._ui = {};
|
|
1114
|
-
this._assistants = [];
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
connectedCallback() {
|
|
1118
|
-
this.renderShell();
|
|
1119
|
-
this.refreshConversations();
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
get widget() {
|
|
1123
|
-
const targetId = this.getAttribute('for_widget');
|
|
1124
|
-
if (targetId) {
|
|
1125
|
-
return document.getElementById(targetId);
|
|
1126
|
-
}
|
|
1127
|
-
return this.closest('capp-card,div')?.querySelector('joe-ai-widget') || null;
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
renderShell() {
|
|
1131
|
-
const style = document.createElement('style');
|
|
1132
|
-
style.textContent = `
|
|
1133
|
-
:host {
|
|
1134
|
-
display: block;
|
|
1135
|
-
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
1136
|
-
font-size: 12px;
|
|
1137
|
-
}
|
|
1138
|
-
.container {
|
|
1139
|
-
display: flex;
|
|
1140
|
-
flex-direction: column;
|
|
1141
|
-
height: 100%;
|
|
1142
|
-
}
|
|
1143
|
-
.header {
|
|
1144
|
-
display: flex;
|
|
1145
|
-
align-items: center;
|
|
1146
|
-
justify-content: space-between;
|
|
1147
|
-
margin-bottom: 4px;
|
|
1148
|
-
font-weight: 600;
|
|
1149
|
-
}
|
|
1150
|
-
.status {
|
|
1151
|
-
font-size: 11px;
|
|
1152
|
-
margin-bottom: 4px;
|
|
1153
|
-
}
|
|
1154
|
-
.row {
|
|
1155
|
-
margin-bottom: 2px;
|
|
1156
|
-
}
|
|
1157
|
-
.row.chips {
|
|
1158
|
-
display: flex;
|
|
1159
|
-
gap: 4px;
|
|
1160
|
-
flex-wrap: wrap;
|
|
1161
|
-
}
|
|
1162
|
-
.chip {
|
|
1163
|
-
display: inline-block;
|
|
1164
|
-
padding: 1px 6px;
|
|
1165
|
-
border-radius: 999px;
|
|
1166
|
-
font-size: 10px;
|
|
1167
|
-
line-height: 1.4;
|
|
1168
|
-
white-space: nowrap;
|
|
1169
|
-
}
|
|
1170
|
-
.row.link a {
|
|
1171
|
-
font-size: 11px;
|
|
1172
|
-
}
|
|
1173
|
-
ul {
|
|
1174
|
-
list-style: none;
|
|
1175
|
-
padding: 0;
|
|
1176
|
-
margin: 0;
|
|
1177
|
-
flex: 1 1 auto;
|
|
1178
|
-
overflow-y: auto;
|
|
1179
|
-
}
|
|
1180
|
-
li {
|
|
1181
|
-
cursor: pointer;
|
|
1182
|
-
padding: 3px 0;
|
|
1183
|
-
border-bottom: 1px solid #e5e7eb;
|
|
1184
|
-
}
|
|
1185
|
-
li:hover {
|
|
1186
|
-
background: #f9fafb;
|
|
1187
|
-
}
|
|
1188
|
-
`;
|
|
1189
|
-
const wrapper = document.createElement('div');
|
|
1190
|
-
wrapper.className = 'container';
|
|
1191
|
-
wrapper.innerHTML = `
|
|
1192
|
-
<div class="header">
|
|
1193
|
-
<span>Widget Conversations</span>
|
|
1194
|
-
<button id="refresh" style="font-size:11px;padding:2px 6px;">Refresh</button>
|
|
1195
|
-
</div>
|
|
1196
|
-
<div id="status" class="status"></div>
|
|
1197
|
-
<ul id="list"></ul>
|
|
1198
|
-
`;
|
|
1199
|
-
this.shadowRoot.innerHTML = '';
|
|
1200
|
-
this.shadowRoot.appendChild(style);
|
|
1201
|
-
this.shadowRoot.appendChild(wrapper);
|
|
1202
|
-
this._ui.status = this.shadowRoot.getElementById('status');
|
|
1203
|
-
this._ui.list = this.shadowRoot.getElementById('list');
|
|
1204
|
-
this._ui.refresh = this.shadowRoot.getElementById('refresh');
|
|
1205
|
-
this._ui.refresh.addEventListener('click', () => this.refreshConversations());
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
async refreshConversations() {
|
|
1209
|
-
const statusEl = this._ui.status;
|
|
1210
|
-
const listEl = this._ui.list;
|
|
1211
|
-
if (!statusEl || !listEl) return;
|
|
1212
|
-
statusEl.textContent = 'Loading conversations...';
|
|
1213
|
-
listEl.innerHTML = '';
|
|
1214
|
-
try {
|
|
1215
|
-
// Load assistants once for name/color lookup
|
|
1216
|
-
if (!this._assistants || !this._assistants.length) {
|
|
1217
|
-
try {
|
|
1218
|
-
const aRes = await fetch('/API/item/ai_assistant', { credentials: 'include' });
|
|
1219
|
-
const aData = await aRes.json();
|
|
1220
|
-
this._assistants = Array.isArray(aData.item) ? aData.item : [];
|
|
1221
|
-
} catch (_e) {
|
|
1222
|
-
this._assistants = [];
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
const res = await fetch('/API/item/ai_widget_conversation', { credentials: 'include' });
|
|
1227
|
-
const data = await res.json();
|
|
1228
|
-
let items = Array.isArray(data.item) ? data.item : [];
|
|
1229
|
-
const allItems = items.slice();
|
|
1230
|
-
const sourceFilter = this.getAttribute('source');
|
|
1231
|
-
let filtered = false;
|
|
1232
|
-
if (sourceFilter) {
|
|
1233
|
-
items = items.filter(c => c.source === sourceFilter);
|
|
1234
|
-
filtered = true;
|
|
1235
|
-
}
|
|
1236
|
-
if (!items.length) {
|
|
1237
|
-
if (filtered && allItems.length) {
|
|
1238
|
-
// Fallback: no conversations for this source; show all instead.
|
|
1239
|
-
items = allItems;
|
|
1240
|
-
statusEl.textContent = 'No conversations for source "' + sourceFilter + '". Showing all widget conversations.';
|
|
1241
|
-
} else {
|
|
1242
|
-
statusEl.textContent = 'No widget conversations found.';
|
|
1243
|
-
return;
|
|
1244
|
-
}
|
|
1245
|
-
}
|
|
1246
|
-
items.sort((a, b) => {
|
|
1247
|
-
const da = a.last_message_at || a.joeUpdated || a.created || '';
|
|
1248
|
-
const db = b.last_message_at || b.joeUpdated || b.created || '';
|
|
1249
|
-
return db.localeCompare(da);
|
|
1250
|
-
});
|
|
1251
|
-
statusEl.textContent = 'Click a conversation to resume it.';
|
|
1252
|
-
items.forEach(c => {
|
|
1253
|
-
const li = document.createElement('li');
|
|
1254
|
-
li.dataset.id = c._id;
|
|
1255
|
-
|
|
1256
|
-
const ts = c.last_message_at || c.joeUpdated || c.created || '';
|
|
1257
|
-
const prettyTs = (typeof _joe !== 'undefined' && _joe.Utils && typeof _joe.Utils.prettyPrintDTS === 'function')
|
|
1258
|
-
? _joe.Utils.prettyPrintDTS(ts)
|
|
1259
|
-
: ts;
|
|
1260
|
-
|
|
1261
|
-
// 1. Conversation title: name or pretty date-time
|
|
1262
|
-
const title = c.name || prettyTs || c._id;
|
|
1263
|
-
|
|
1264
|
-
// Helper to choose readable foreground for a hex bg
|
|
1265
|
-
function textColorForBg(hex) {
|
|
1266
|
-
if (!hex || typeof hex !== 'string' || !/^#?[0-9a-fA-F]{6}$/.test(hex)) return '#000';
|
|
1267
|
-
const h = hex[0] === '#' ? hex.slice(1) : hex;
|
|
1268
|
-
const n = parseInt(h, 16);
|
|
1269
|
-
const r = (n >> 16) & 0xff;
|
|
1270
|
-
const g = (n >> 8) & 0xff;
|
|
1271
|
-
const b = n & 0xff;
|
|
1272
|
-
const luminance = r * 0.299 + g * 0.587 + b * 0.114;
|
|
1273
|
-
return luminance > 186 ? '#000' : '#fff';
|
|
1274
|
-
}
|
|
1275
|
-
|
|
1276
|
-
// 2. User and assistant colored chiclets
|
|
1277
|
-
const userLabel = c.user_name || c.user || '';
|
|
1278
|
-
const userColor = c.user_color || '';
|
|
1279
|
-
let userChip = '';
|
|
1280
|
-
if (userLabel) {
|
|
1281
|
-
const bg = userColor || '#4b5563';
|
|
1282
|
-
const fg = textColorForBg(bg);
|
|
1283
|
-
userChip = `<span class="chip user" style="background:${bg};color:${fg};">${userLabel}</span>`;
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
let asstName = '';
|
|
1287
|
-
let asstColor = '';
|
|
1288
|
-
const asstId = c.assistant;
|
|
1289
|
-
const asstOpenAIId = c.assistant_id;
|
|
1290
|
-
if (this._assistants && this._assistants.length) {
|
|
1291
|
-
const asst = this._assistants.find(a =>
|
|
1292
|
-
(asstId && a && a._id === asstId) ||
|
|
1293
|
-
(asstOpenAIId && a && a.assistant_id === asstOpenAIId)
|
|
1294
|
-
);
|
|
1295
|
-
if (asst) {
|
|
1296
|
-
asstName = asst.name || asst.title || asst.assistant_id || asst._id || 'Assistant';
|
|
1297
|
-
asstColor = asst.assistant_color || asst.color || c.assistant_color || '';
|
|
1298
|
-
}
|
|
1299
|
-
}
|
|
1300
|
-
if (!asstName && (asstOpenAIId || asstId)) {
|
|
1301
|
-
asstName = asstOpenAIId || asstId;
|
|
1302
|
-
asstColor = c.assistant_color || '';
|
|
1303
|
-
}
|
|
1304
|
-
let asstChip = '';
|
|
1305
|
-
if (asstName) {
|
|
1306
|
-
const bg = asstColor || '#2563eb';
|
|
1307
|
-
const fg = textColorForBg(bg);
|
|
1308
|
-
asstChip = `<span class="chip assistant" style="background:${bg};color:${fg};">${asstName}</span>`;
|
|
1309
|
-
}
|
|
1310
|
-
|
|
1311
|
-
// 3. Source and # of messages
|
|
1312
|
-
const msgCount = Array.isArray(c.messages) ? c.messages.length : 0;
|
|
1313
|
-
const src = c.source || '';
|
|
1314
|
-
const metaParts = [];
|
|
1315
|
-
if (src) metaParts.push(src);
|
|
1316
|
-
metaParts.push(msgCount + ' msgs');
|
|
1317
|
-
const metaLine = metaParts.join(' · ');
|
|
1318
|
-
|
|
1319
|
-
// 4. Link to the widget conversation in JOE
|
|
1320
|
-
let href = '#/ai_widget_conversation/' + encodeURIComponent(c._id);
|
|
1321
|
-
try {
|
|
1322
|
-
if (window && window.location && typeof window.location.href === 'string') {
|
|
1323
|
-
href = window.location.href.replace(window.location.hash, href);
|
|
1324
|
-
}
|
|
1325
|
-
} catch (_e) { /* ignore */ }
|
|
1326
|
-
|
|
1327
|
-
li.innerHTML = ''
|
|
1328
|
-
+ `<div class="row title">${title}</div>`
|
|
1329
|
-
+ `<div class="row chips">${userChip}${asstChip}</div>`
|
|
1330
|
-
+ `<div class="row meta">${metaLine}</div>`
|
|
1331
|
-
+ `<div class="row link"><a href="${href}" target="_blank" rel="noopener" onclick="event.stopPropagation();">open in JOE</a></div>`;
|
|
1332
|
-
|
|
1333
|
-
li.addEventListener('click', () => {
|
|
1334
|
-
this.applyConversationToWidget(this.widget, c._id);
|
|
1335
|
-
});
|
|
1336
|
-
listEl.appendChild(li);
|
|
1337
|
-
});
|
|
1338
|
-
} catch (e) {
|
|
1339
|
-
console.error('[joe-ai] error loading widget conversations', e);
|
|
1340
|
-
statusEl.textContent = 'Error loading conversations; see console.';
|
|
1341
|
-
}
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
applyConversationToWidget(widget, conversationId) {
|
|
1345
|
-
if (!widget || !conversationId) return;
|
|
1346
|
-
widget.setAttribute('conversation_id', conversationId);
|
|
1347
|
-
widget.conversation_id = conversationId;
|
|
1348
|
-
if (typeof widget.loadHistory === 'function') {
|
|
1349
|
-
widget.loadHistory();
|
|
1350
|
-
} else if (widget.connectedCallback) {
|
|
1351
|
-
widget.connectedCallback();
|
|
1352
|
-
}
|
|
1353
|
-
if (typeof widget.persistState === 'function') {
|
|
1354
|
-
widget.persistState();
|
|
1355
|
-
}
|
|
1356
|
-
}
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
if (!customElements.get('joe-ai-conversation-list')) {
|
|
1360
|
-
customElements.define('joe-ai-conversation-list', JoeAIConversationList);
|
|
1361
|
-
}
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
//**YES**
|
|
1365
|
-
Ai.spawnChatHelper = async function(object_id,user_id=_joe.User._id,conversation_id) {
|
|
1366
|
-
//if not conversation_id, create a new one
|
|
1367
|
-
let convo_id = conversation_id;
|
|
1368
|
-
var newChat = false;
|
|
1369
|
-
if(!convo_id){
|
|
1370
|
-
const response = await fetch('/API/plugin/chatgpt-assistants/createConversation', {
|
|
1371
|
-
method: 'POST',
|
|
1372
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1373
|
-
body: JSON.stringify({
|
|
1374
|
-
object_id,
|
|
1375
|
-
user_id
|
|
1376
|
-
})
|
|
1377
|
-
}).then(res => res.json());
|
|
1378
|
-
convo_id = response?.conversation?._id;
|
|
1379
|
-
newChat = true;
|
|
1380
|
-
if(response.error){
|
|
1381
|
-
console.error('❌ Failed to create conversation:', response.error);
|
|
1382
|
-
alert('Failed to create conversation: '+response.message);
|
|
1383
|
-
return;
|
|
1384
|
-
}
|
|
1385
|
-
}
|
|
1386
|
-
|
|
1387
|
-
await _joe.Ai.spawnContextualChat(convo_id,{object_id,newChat});
|
|
1388
|
-
}
|
|
1389
|
-
Ai.getDefaultAssistant = function() {
|
|
1390
|
-
|
|
1391
|
-
Ai.default_ai = Ai.default_ai ||_joe.Data.setting.where({name:'DEFAULT_AI_ASSISTANT'})[0]||false;
|
|
1392
|
-
if(!Ai.default_ai){alert('No default AI assistant found');}
|
|
1393
|
-
return Ai.default_ai;
|
|
1394
|
-
}
|
|
1395
|
-
// ========== HELPERS ==========
|
|
1396
|
-
Ai.spawnContextualChat = async function(conversationId, options = {}) {
|
|
1397
|
-
if (!conversationId) {
|
|
1398
|
-
console.warn("Missing conversation ID for chat spawn.");
|
|
1399
|
-
return;
|
|
1400
|
-
}
|
|
1401
|
-
Ai.getDefaultAssistant();
|
|
1402
|
-
|
|
1403
|
-
// 1. Check if chat already open
|
|
1404
|
-
if (Ai._openChats[conversationId]) {
|
|
1405
|
-
console.log("Chatbox already open for", conversationId);
|
|
1406
|
-
Ai._openChats[conversationId].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
1407
|
-
return;
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
try {
|
|
1411
|
-
const flattened = _joe.Object.flatten(options.object_id);
|
|
1412
|
-
if (options.newChat) {
|
|
1413
|
-
// 2. Prepare context
|
|
1414
|
-
|
|
1415
|
-
const contextInstructions = _joe.Ai.generateContextInstructions(flattened,options.object_id);
|
|
1416
|
-
|
|
1417
|
-
// 3. Inject context into backend
|
|
1418
|
-
const contextResult = await fetch('/API/plugin/chatgpt-assistants/addMessage', {
|
|
1419
|
-
method: 'POST',
|
|
1420
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1421
|
-
body: JSON.stringify({
|
|
1422
|
-
conversation_id: conversationId,
|
|
1423
|
-
role: 'JOE',
|
|
1424
|
-
content: contextInstructions,
|
|
1425
|
-
assistant_id: Ai.default_ai.value,
|
|
1426
|
-
object_id: options.object_id
|
|
1427
|
-
})
|
|
1428
|
-
}).then(res => res.json());
|
|
1429
|
-
|
|
1430
|
-
if (!contextResult || contextResult.error) {
|
|
1431
|
-
console.error('❌ Failed to inject context:', contextResult?.error);
|
|
1432
|
-
return;
|
|
1433
|
-
}
|
|
1434
|
-
}
|
|
1435
|
-
// 4. Create new chatbox
|
|
1436
|
-
const chat = document.createElement('joe-ai-chatbox');
|
|
1437
|
-
chat.setAttribute('conversation_id', conversationId);
|
|
1438
|
-
const screenWidth = window.innerWidth;
|
|
1439
|
-
if(screenWidth <= 768){
|
|
1440
|
-
chat.setAttribute('mobile', 'true');
|
|
1441
|
-
chat.style.width = 'auto';
|
|
1442
|
-
chat.style.left = '0px';
|
|
1443
|
-
}
|
|
1444
|
-
else{
|
|
1445
|
-
chat.setAttribute('mobile', 'false');
|
|
1446
|
-
chat.style.width = options.width || '640px';
|
|
1447
|
-
chat.style.left = 'auto';
|
|
1448
|
-
}
|
|
1449
|
-
// Apply styles
|
|
1450
|
-
|
|
1451
|
-
//chat.style.height = options.height || '420px';
|
|
1452
|
-
chat.style.bottom = options.bottom || '50px';
|
|
1453
|
-
chat.style.right = options.right || '0px';
|
|
1454
|
-
chat.style.top = options.top || '50px';
|
|
1455
|
-
chat.style.position = 'fixed';
|
|
1456
|
-
chat.style.zIndex = '10000';
|
|
1457
|
-
chat.style.background = '#efefef';
|
|
1458
|
-
chat.style.border = '1px solid #fff';
|
|
1459
|
-
chat.style.borderRadius = '8px';
|
|
1460
|
-
chat.style.boxShadow = '0px 1px 4px rgba(0, 0, 0, 0.3)';
|
|
1461
|
-
chat.style.padding = '5px';
|
|
1462
|
-
chat.style.margin = '5px';
|
|
1463
|
-
|
|
1464
|
-
document.body.appendChild(chat);
|
|
1465
|
-
|
|
1466
|
-
// 5. Track it
|
|
1467
|
-
Ai._openChats[conversationId] = chat;
|
|
1468
|
-
|
|
1469
|
-
if (options.newChat) {
|
|
1470
|
-
// 6. Show soft local UI message
|
|
1471
|
-
//_joe.Ai.injectSystemMessage(conversationId, `Context injected: ${flattened.name || flattened.title || 'Object'} (${flattened._id})`);
|
|
1472
|
-
}
|
|
1473
|
-
} catch (err) {
|
|
1474
|
-
console.error('❌ spawnChat context injection failed:', err);
|
|
1475
|
-
}
|
|
1476
|
-
};
|
|
1477
|
-
|
|
1478
|
-
/* Ai.spawnChat = function(conversationId, options = {}) {
|
|
1479
|
-
if (!conversationId) {
|
|
1480
|
-
console.warn("Missing conversation ID for chat spawn.");
|
|
1481
|
-
return;
|
|
1482
|
-
}
|
|
1483
|
-
|
|
1484
|
-
// 1. Check if chatbox already open
|
|
1485
|
-
if (Ai._openChats[conversationId]) {
|
|
1486
|
-
console.log("Chatbox already open for", conversationId);
|
|
1487
|
-
Ai._openChats[conversationId].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
1488
|
-
return;
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
// 2. Create new chatbox
|
|
1492
|
-
const chat = document.createElement('joe-ai-chatbox');
|
|
1493
|
-
chat.setAttribute('conversation_id', conversationId);
|
|
1494
|
-
|
|
1495
|
-
const flattened = _joe.Object.flatten();
|
|
1496
|
-
const contextInstructions = _joe.Ai.generateContextInstructions(flattened);
|
|
1497
|
-
|
|
1498
|
-
// Actually inject into AI backend (for assistant awareness) if you have that later
|
|
1499
|
-
// For now: silently show a soft system bubble
|
|
1500
|
-
_joe.Ai.injectSystemMessage(conversationId, `Context injected: ${flattened.name || flattened.title || 'Object'} (${flattened._id})`);
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
// Apply styles
|
|
1504
|
-
chat.style.width = options.width || '400px';
|
|
1505
|
-
chat.style.height = options.height || '420px';
|
|
1506
|
-
chat.style.bottom = options.bottom || '20px';
|
|
1507
|
-
chat.style.right = options.right || '20px';
|
|
1508
|
-
chat.style.position = 'fixed';
|
|
1509
|
-
chat.style.zIndex = '10000';
|
|
1510
|
-
chat.style.background = '#efefef';
|
|
1511
|
-
chat.style.border = '1px solid #fff';
|
|
1512
|
-
chat.style.borderRadius = '8px';
|
|
1513
|
-
chat.style.boxShadow = '0px 2px 10px rgba(0,0,0,0.1)';
|
|
1514
|
-
chat.style.padding = '5px';
|
|
1515
|
-
|
|
1516
|
-
document.body.appendChild(chat);
|
|
1517
|
-
|
|
1518
|
-
// 3. Track it
|
|
1519
|
-
Ai._openChats[conversationId] = chat;
|
|
1520
|
-
return chat;
|
|
1521
|
-
// 4. Optionally clean up when chatbox is removed (if you wire close buttons later)
|
|
1522
|
-
};
|
|
1523
|
-
*/
|
|
1524
|
-
Ai.generateContextInstructions = function(flattenedObj,object_id) {
|
|
1525
|
-
if (!flattenedObj) return '';
|
|
1526
|
-
|
|
1527
|
-
let context = `{{{BEGIN_OBJECT:${object_id}}}}`+
|
|
1528
|
-
"Context: You are assisting the user with the following object:\n\n";
|
|
1529
|
-
|
|
1530
|
-
context += JSON.stringify(flattenedObj, null, 2) + "\n\n";
|
|
1531
|
-
// for (const [key, value] of Object.entries(flattenedObj)) {
|
|
1532
|
-
// if (typeof value === 'object' && value !== null) {
|
|
1533
|
-
// context += `- ${key}: (linked object)\n`;
|
|
1534
|
-
// for (const [subkey, subval] of Object.entries(value)) {
|
|
1535
|
-
// context += ` • ${subkey}: ${subval}\n`;
|
|
1536
|
-
// }
|
|
1537
|
-
// } else {
|
|
1538
|
-
// context += `- ${key}: ${value}\n`;
|
|
1539
|
-
// }
|
|
1540
|
-
// }
|
|
1541
|
-
|
|
1542
|
-
context += `\nAlways refer to this context when answering questions or completing tasks related to this object.\n`+
|
|
1543
|
-
`{{{END_OBJECT:${object_id}}}}`;
|
|
1544
|
-
|
|
1545
|
-
return context;
|
|
1546
|
-
};
|
|
1547
|
-
|
|
1548
|
-
Ai.injectSystemMessage = async function(conversationId, text) {
|
|
1549
|
-
if (!conversationId || !text) return;
|
|
1550
|
-
|
|
1551
|
-
try {
|
|
1552
|
-
// Create a system-style message object
|
|
1553
|
-
const messageObj = {
|
|
1554
|
-
conversation_id: conversationId,
|
|
1555
|
-
role: 'joe',
|
|
1556
|
-
content: text,
|
|
1557
|
-
created: new Date().toISOString()
|
|
1558
|
-
};
|
|
1559
|
-
|
|
1560
|
-
// You could either push this directly into chatbox if loaded, or update server messages if you have backend ready
|
|
1561
|
-
const chatbox = document.querySelector(`joe-ai-chatbox[conversation_id="${conversationId}"]`);
|
|
1562
|
-
if (chatbox) {
|
|
1563
|
-
if (!chatbox.messages) {
|
|
1564
|
-
chatbox.messages = [];
|
|
1565
|
-
}
|
|
1566
|
-
chatbox.messages.push(messageObj);
|
|
1567
|
-
|
|
1568
|
-
// Optionally trigger a soft re-render of chatbox if needed
|
|
1569
|
-
if (typeof chatbox.renderMessages === 'function') {
|
|
1570
|
-
chatbox.renderMessages();
|
|
1571
|
-
}
|
|
1572
|
-
}
|
|
1573
|
-
} catch (err) {
|
|
1574
|
-
console.error("❌ injectSystemMessage failed:", err);
|
|
1575
|
-
}
|
|
1576
|
-
};
|
|
1577
|
-
|
|
1578
|
-
// ---------- Autofill (Responses) ----------
|
|
1579
|
-
// Usage from schema: add `ai:{ prompt:'...' }` to a field. Core will render a
|
|
1580
|
-
// button that calls `_joe.Ai.populateField('field_name')`. You can also call
|
|
1581
|
-
// `_joe.Ai.populateField(dom, { prompt:'...' })` manually from a button.
|
|
1582
|
-
Ai.populateField = async function(target, options = {}){
|
|
1583
|
-
try{
|
|
1584
|
-
const obj = _joe.current && _joe.current.object;
|
|
1585
|
-
const schema = _joe.current && _joe.current.schema && _joe.current.schema.__schemaname;
|
|
1586
|
-
if(!obj || !schema){ return alert('No current object/schema found'); }
|
|
1587
|
-
|
|
1588
|
-
let dom = null;
|
|
1589
|
-
let explicitField = null;
|
|
1590
|
-
if (typeof target === 'string') {
|
|
1591
|
-
explicitField = target;
|
|
1592
|
-
} else {
|
|
1593
|
-
dom = target;
|
|
1594
|
-
}
|
|
1595
|
-
|
|
1596
|
-
let fields = [];
|
|
1597
|
-
if (options.fields && options.fields.length) {
|
|
1598
|
-
fields = options.fields;
|
|
1599
|
-
} else if (explicitField) {
|
|
1600
|
-
fields = [explicitField];
|
|
1601
|
-
} else if (dom) {
|
|
1602
|
-
const parentField = $(dom).parents('.joe-object-field');
|
|
1603
|
-
const inferredField = parentField.data('name');
|
|
1604
|
-
if (inferredField) { fields = [inferredField]; }
|
|
1605
|
-
}
|
|
1606
|
-
if(!fields.length){ return alert('No target field detected. Configure ai.fields or pass a field name.'); }
|
|
1607
|
-
|
|
1608
|
-
// If no prompt was passed explicitly, try to pick it up from the field's
|
|
1609
|
-
// schema-level ai config. Also inherit allowWeb from field.ai.allowWeb|allow_web
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
}
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
}
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
if (
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
}
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
if(
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
const
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
}
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
$el.
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
var
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
if(
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
}
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
}
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
1
|
+
(function(){
|
|
2
|
+
// Define the joeAI namespace
|
|
3
|
+
const Ai = {};
|
|
4
|
+
const self = this;
|
|
5
|
+
Ai._openChats = {}; // Conversation ID -> element
|
|
6
|
+
Ai.default_ai = null; // Default AI assistant object
|
|
7
|
+
// ========== COMPONENTS ==========
|
|
8
|
+
|
|
9
|
+
// Simple markdown -> HTML helper used by chat UI components.
|
|
10
|
+
// Uses global `marked` and `DOMPurify` when available, falls back to
|
|
11
|
+
// basic escaping + <br> conversion otherwise.
|
|
12
|
+
function renderMarkdownSafe(text) {
|
|
13
|
+
const raw = text || '';
|
|
14
|
+
if (typeof window !== 'undefined' && window.marked && window.DOMPurify) {
|
|
15
|
+
try {
|
|
16
|
+
const html = window.marked.parse(raw);
|
|
17
|
+
return window.DOMPurify.sanitize(html);
|
|
18
|
+
} catch (e) {
|
|
19
|
+
console.error('[joe-ai] markdown render error', e);
|
|
20
|
+
// fall through to plain escape
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return raw
|
|
24
|
+
.replace(/&/g, '&')
|
|
25
|
+
.replace(/</g, '<')
|
|
26
|
+
.replace(/>/g, '>')
|
|
27
|
+
.replace(/\n/g, '<br>');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class JoeAIChatbox extends HTMLElement {
|
|
31
|
+
constructor() {
|
|
32
|
+
super();
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
this.attachShadow({ mode: 'open' });
|
|
36
|
+
this.messages = [];
|
|
37
|
+
this.UI={header:null,content:null,footer:null};
|
|
38
|
+
this.conversation = null;
|
|
39
|
+
this.conversation_id = null;
|
|
40
|
+
this.currentRunId = null;
|
|
41
|
+
this.thread_id = null;
|
|
42
|
+
this.user_id = null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async connectedCallback() {
|
|
46
|
+
this.conversation_id = this.getAttribute('conversation_id');
|
|
47
|
+
|
|
48
|
+
this.selected_assistant_id = Ai.default_ai?Ai.default_ai.value:null;
|
|
49
|
+
this.ui = {};
|
|
50
|
+
if (!this.conversation_id) {
|
|
51
|
+
this.renderError("Missing conversation_id");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
var c = await this.loadConversation();
|
|
56
|
+
this.getAllAssistants();
|
|
57
|
+
this.setupUI();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
setupUI() {
|
|
61
|
+
// Set up UI elements and event listeners
|
|
62
|
+
// Inject external CSS
|
|
63
|
+
const styleLink = document.createElement('link');
|
|
64
|
+
styleLink.setAttribute('rel', 'stylesheet');
|
|
65
|
+
styleLink.setAttribute('href', '/JsonObjectEditor/css/joe-ai.css'); // Adjust path as needed
|
|
66
|
+
this.shadowRoot.appendChild(styleLink);
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
Ai.default_ai
|
|
70
|
+
/*HEADER*/
|
|
71
|
+
Ai.getDefaultAssistant();
|
|
72
|
+
const assistantOptions = _joe.Data.ai_assistant.map(a => {
|
|
73
|
+
|
|
74
|
+
const label = a.name || a.title || 'Assistant';
|
|
75
|
+
const value = a._id;
|
|
76
|
+
const selected = value === this.selected_assistant_id ? 'selected' : '';
|
|
77
|
+
|
|
78
|
+
return `<option value="${value}" ${selected}>${label}</option>`;
|
|
79
|
+
}).join('');
|
|
80
|
+
|
|
81
|
+
const assistantSelect = `
|
|
82
|
+
<label-select-wrapper>
|
|
83
|
+
<label class="assistant-select-label" title="joe ai assistants">${_joe && _joe.SVG.icon.assistant}</label>
|
|
84
|
+
<select id="assistant-select">
|
|
85
|
+
${assistantOptions}
|
|
86
|
+
</select>
|
|
87
|
+
</label-select-wrapper>
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
/*CONTENT*/
|
|
91
|
+
const chatMessages = this.messages.map(msg => this.renderMessage(msg)).reverse().join('');
|
|
92
|
+
|
|
93
|
+
// Build inner HTML in a wrapper
|
|
94
|
+
const wrapper = document.createElement('chatbox-wrapper');
|
|
95
|
+
wrapper.className = 'chatbox';
|
|
96
|
+
wrapper.innerHTML = `
|
|
97
|
+
<div class="close-btn" title="Close Chatbox" >${_joe.SVG.icon.close}</div>
|
|
98
|
+
<chat-header>
|
|
99
|
+
<chat-title>${this.conversation.name}</chat-title>
|
|
100
|
+
<p>${this.conversation.info||''}</p>
|
|
101
|
+
${assistantSelect}
|
|
102
|
+
</chat-header>
|
|
103
|
+
<chat-content>${chatMessages}</chat-content>
|
|
104
|
+
<chat-footer>
|
|
105
|
+
<textarea id="chat-input" type="text" placeholder="Type a message..."></textarea>
|
|
106
|
+
<button id="send-button">Send</button>
|
|
107
|
+
</chat-footer>
|
|
108
|
+
`;
|
|
109
|
+
|
|
110
|
+
this.shadowRoot.appendChild(wrapper);
|
|
111
|
+
|
|
112
|
+
['header','content','header'].map(u=>{
|
|
113
|
+
this.UI[u] = this.shadowRoot.querySelector('chat-'+u);
|
|
114
|
+
})
|
|
115
|
+
this.UI.content.update = this.updateMessages.bind(this);
|
|
116
|
+
setTimeout(() => {
|
|
117
|
+
this.UI.content.scrollTop = this.UI.content.scrollHeight;
|
|
118
|
+
}, 100);
|
|
119
|
+
this.UI.content.scrollTop = this.UI.content.scrollHeight;
|
|
120
|
+
|
|
121
|
+
this.UI.textarea = this.shadowRoot.getElementById('chat-input');
|
|
122
|
+
this.UI.textarea.addEventListener('keydown', (e) => {
|
|
123
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
124
|
+
e.preventDefault(); // Prevent newline if needed
|
|
125
|
+
console.log('Enter pressed!');
|
|
126
|
+
this.sendMessage();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
this.UI.sendButton = this.shadowRoot.getElementById('send-button');
|
|
131
|
+
this.UI.sendButton.addEventListener('click', () => this.sendMessage());
|
|
132
|
+
|
|
133
|
+
// Wire up the close button
|
|
134
|
+
this.shadowRoot.querySelector('.close-btn').addEventListener('click', () => {
|
|
135
|
+
//this.closeChat()
|
|
136
|
+
this.closest('joe-ai-chatbox').closeChat();
|
|
137
|
+
});
|
|
138
|
+
// this.ui.assistant_select = this.shadowRoot.querySelector('#assistant-select');
|
|
139
|
+
// this.ui.assistant_select?.addEventListener('change', (e) => {
|
|
140
|
+
// this.selected_assistant_id = e.target.value;
|
|
141
|
+
// });
|
|
142
|
+
//this.selected_assistant_id = this.ui.assistant_select?.value || null;
|
|
143
|
+
|
|
144
|
+
}
|
|
145
|
+
updateMessages(messages){
|
|
146
|
+
messages && (this.messages = messages);
|
|
147
|
+
const chatMessages = this.messages.map(msg => this.renderMessage(msg)).reverse().join('');
|
|
148
|
+
this.UI.content.innerHTML = chatMessages;
|
|
149
|
+
this.UI.content.scrollTop = this.UI.content.scrollHeight;
|
|
150
|
+
}
|
|
151
|
+
async getAllAssistants() {
|
|
152
|
+
const res = await fetch('/API/item/ai_assistant');
|
|
153
|
+
const result = await res.json();
|
|
154
|
+
if (result.error) {
|
|
155
|
+
console.error("Error fetching assistants:", result.error);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
this.allAssistants = {};
|
|
159
|
+
|
|
160
|
+
if (Array.isArray(result.item)) {
|
|
161
|
+
result.item.map(a => {
|
|
162
|
+
if (a._id) {
|
|
163
|
+
this.allAssistants[a._id] = a;
|
|
164
|
+
}
|
|
165
|
+
if (a.openai_id) {
|
|
166
|
+
this.allAssistants[a.openai_id] = a; // Optional dual-key if you prefer
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
}
|
|
172
|
+
async loadConversation() {
|
|
173
|
+
//load conversation and messages into this
|
|
174
|
+
try {
|
|
175
|
+
const res = await fetch(`/API/object/ai_conversation/_id/${this.conversation_id}`);
|
|
176
|
+
const convo = await res.json();
|
|
177
|
+
|
|
178
|
+
if (!convo || convo.error) {
|
|
179
|
+
this.renderError("Conversation not found.");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
this.conversation = convo;
|
|
183
|
+
this.messages = [];
|
|
184
|
+
|
|
185
|
+
if (convo.thread_id) {
|
|
186
|
+
const resThread = await fetch(`/API/plugin/chatgpt-assistants/getThreadMessages?thread_id=${convo.thread_id}`);
|
|
187
|
+
const threadMessages = await resThread.json();
|
|
188
|
+
this.messages = threadMessages?.messages || [];
|
|
189
|
+
this.thread_id = convo.thread_id;
|
|
190
|
+
this.user = $J.get(convo.user);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// If there is no assistant reply yet, inject a local greeting based on context.
|
|
194
|
+
// This still shows even if we have a single PLATFORM/system card from context.
|
|
195
|
+
const hasAssistantReply = Array.isArray(this.messages) &&
|
|
196
|
+
this.messages.some(m => m.role === 'assistant');
|
|
197
|
+
if (!hasAssistantReply) {
|
|
198
|
+
try {
|
|
199
|
+
const user = this.user || (convo.user && $J.get(convo.user)) || {};
|
|
200
|
+
const contextId = (convo.context_objects && convo.context_objects[0]) || null;
|
|
201
|
+
const ctxObj = contextId ? $J.get(contextId) : null;
|
|
202
|
+
const uname = user.name || 'there';
|
|
203
|
+
let target = 'this conversation';
|
|
204
|
+
if (ctxObj) {
|
|
205
|
+
const label = ctxObj.name || ctxObj.title || ctxObj.label || ctxObj._id;
|
|
206
|
+
target = `${label} (${ctxObj.itemtype || 'item'})`;
|
|
207
|
+
}
|
|
208
|
+
const greeting = `Hi ${uname}, I’m ready to help you with ${target}. What would you like to explore or change?`;
|
|
209
|
+
this.messages.push({
|
|
210
|
+
role: 'assistant',
|
|
211
|
+
content: greeting,
|
|
212
|
+
created_at: Math.floor(Date.now()/1000)
|
|
213
|
+
});
|
|
214
|
+
} catch(_e){}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {conversation:convo,messages:this.messages};
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.error("Chatbox load error:", err);
|
|
220
|
+
this.renderError("Error loading conversation.");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
getAssistantInfo(id){
|
|
224
|
+
const assistant = this.allAssistants[id];
|
|
225
|
+
if (assistant) {
|
|
226
|
+
return assistant;
|
|
227
|
+
} else {
|
|
228
|
+
console.warn("Assistant not found:", id);
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
render() {
|
|
233
|
+
const convoName = this.conversation.name || "Untitled Conversation";
|
|
234
|
+
const convoInfo = this.conversation.info || "";
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
}
|
|
245
|
+
processMessageText(text) {
|
|
246
|
+
|
|
247
|
+
// const replaced = text.replace(
|
|
248
|
+
// /\{\{\{BEGIN_OBJECT:(.*?)\}\}\}[\s\S]*?\{\{\{END_OBJECT:\1\}\}\}/g,
|
|
249
|
+
// (match, cuid) => `<joe-object object_id="${cuid}"></joe-object>`
|
|
250
|
+
// );
|
|
251
|
+
let didReplace = false;
|
|
252
|
+
|
|
253
|
+
const replaced = text.replace(
|
|
254
|
+
/\{\{\{BEGIN_OBJECT:(.*?)\}\}\}[\s\S]*?\{\{\{END_OBJECT:\1\}\}\}/g,
|
|
255
|
+
(match, cuid) => {
|
|
256
|
+
didReplace = true;
|
|
257
|
+
return `<joe-object object_id="${cuid}"></joe-object>`;
|
|
258
|
+
}
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
return {text:replaced, replaced:didReplace};
|
|
262
|
+
}
|
|
263
|
+
renderMessage(msg) {
|
|
264
|
+
const role = msg.role || 'user';
|
|
265
|
+
const classes = `message ${role}`;
|
|
266
|
+
var username = role;
|
|
267
|
+
if(role === 'user'){
|
|
268
|
+
username = this.user.name||'User';
|
|
269
|
+
}
|
|
270
|
+
let contentText = '';
|
|
271
|
+
|
|
272
|
+
if (Array.isArray(msg.content)) {
|
|
273
|
+
// OpenAI style: array of parts
|
|
274
|
+
contentText = msg.content.map(part => {
|
|
275
|
+
if (part.type === 'text' && part.text && part.text.value) {
|
|
276
|
+
return part.text.value;
|
|
277
|
+
}
|
|
278
|
+
return '';
|
|
279
|
+
}).join('\n');
|
|
280
|
+
} else if (typeof msg.content === 'string') {
|
|
281
|
+
contentText = msg.content;
|
|
282
|
+
} else {
|
|
283
|
+
contentText = '[Unsupported message format]';
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const ctInfo = this.processMessageText(contentText);
|
|
287
|
+
contentText = ctInfo.text;
|
|
288
|
+
if(ctInfo.replaced){
|
|
289
|
+
username = 'platform'
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Build timestamp
|
|
293
|
+
const createdAt = msg.created_at ? new Date(msg.created_at * 1000) : null; // OpenAI sends timestamps in seconds
|
|
294
|
+
const timestamp = createdAt ? createdAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
|
295
|
+
|
|
296
|
+
return `
|
|
297
|
+
<div class="${classes}">
|
|
298
|
+
<div class="meta">
|
|
299
|
+
<participant-name>${username.toUpperCase()}</participant-name>
|
|
300
|
+
${timestamp ? `<span class="timestamp">${timestamp}</span>` : ''}</div>
|
|
301
|
+
<div class="content">${contentText}</div>
|
|
302
|
+
</div>
|
|
303
|
+
`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
renderError(message) {
|
|
307
|
+
this.shadowRoot.innerHTML = `<div style="color:red;">${message}</div>
|
|
308
|
+
<div class="close-btn" title="Close Chatbox" >${_joe.SVG.icon.close}</div>`;
|
|
309
|
+
this.shadowRoot.querySelector('.close-btn').addEventListener('click', () => {
|
|
310
|
+
//this.closeChat()
|
|
311
|
+
this.closest('joe-ai-chatbox').closeChat();
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
async getResponse(conversation_id,content,role,assistant_id){
|
|
317
|
+
const response = await fetch('/API/plugin/chatgpt-assistants/addMessage', {
|
|
318
|
+
method: 'POST',
|
|
319
|
+
headers: { 'Content-Type': 'application/json' },
|
|
320
|
+
body: JSON.stringify({
|
|
321
|
+
conversation_id: conversation_id,
|
|
322
|
+
content: content,
|
|
323
|
+
role: role||'system',
|
|
324
|
+
assistant_id: assistant_id||Ai.default_ai?Ai.default_ai.value:null
|
|
325
|
+
})
|
|
326
|
+
}).then(res => res.json());
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async sendMessage() {//** */
|
|
330
|
+
|
|
331
|
+
const input = this.UI.textarea;
|
|
332
|
+
const message = input.value.trim();
|
|
333
|
+
if (!message) return;
|
|
334
|
+
|
|
335
|
+
input.disabled = true;
|
|
336
|
+
this.UI.sendButton.disabled = true;
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
const response = await fetch('/API/plugin/chatgpt-assistants/addMessage', {
|
|
340
|
+
method: 'POST',
|
|
341
|
+
headers: { 'Content-Type': 'application/json' },
|
|
342
|
+
body: JSON.stringify({
|
|
343
|
+
conversation_id: this.conversation_id,
|
|
344
|
+
content: message,
|
|
345
|
+
assistant_id: this.selected_assistant_id || (Ai.default_ai ? Ai.default_ai.value : null)
|
|
346
|
+
})
|
|
347
|
+
}).then(res => res.json());
|
|
348
|
+
|
|
349
|
+
if (response.success && (response.run_id || (response.runObj && response.runObj.id))) {
|
|
350
|
+
// Use IDs from server so we don't depend on cache or stale conversation data
|
|
351
|
+
const threadId = response.thread_id || this.conversation.thread_id;
|
|
352
|
+
this.conversation.thread_id = threadId;
|
|
353
|
+
this.currentRunId = response.run_id || (response.runObj && response.runObj.id);
|
|
354
|
+
|
|
355
|
+
// Reload conversation (name, info, etc.), but preserve the known thread_id
|
|
356
|
+
await this.loadConversation();
|
|
357
|
+
if (!this.conversation.thread_id && threadId) {
|
|
358
|
+
this.conversation.thread_id = threadId;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
this.UI.content.update(); // update messages
|
|
362
|
+
this.startPolling(); // 🌸 start watching for assistant reply!
|
|
363
|
+
input.value = '';
|
|
364
|
+
} else {
|
|
365
|
+
alert((response.error||'Failed to send message.')+' - '+response.message);
|
|
366
|
+
}
|
|
367
|
+
} catch (err) {
|
|
368
|
+
console.error('Send message error:', err);
|
|
369
|
+
alert('Error sending message.');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
// input.disabled = false;
|
|
374
|
+
// this.shadowRoot.getElementById('send-button').disabled = false;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
startPolling() {
|
|
378
|
+
if (this.pollingInterval) return; // Already polling
|
|
379
|
+
|
|
380
|
+
// Insert thinking message
|
|
381
|
+
this.showThinkingMessage();
|
|
382
|
+
|
|
383
|
+
// Baseline: timestamp of the latest assistant message we have so far
|
|
384
|
+
const lastAssistantTs = (this.messages || [])
|
|
385
|
+
.filter(m => m && m.role === 'assistant' && m.created_at)
|
|
386
|
+
.reduce((max, m) => Math.max(max, m.created_at), 0);
|
|
387
|
+
|
|
388
|
+
let attempts = 0;
|
|
389
|
+
this.pollingInterval = setInterval(async () => {
|
|
390
|
+
attempts++;
|
|
391
|
+
try {
|
|
392
|
+
const resThread = await fetch(`/API/plugin/chatgpt-assistants/getThreadMessages?thread_id=${this.conversation.thread_id}&polling=true`);
|
|
393
|
+
const threadMessages = await resThread.json();
|
|
394
|
+
|
|
395
|
+
if (threadMessages?.messages) {
|
|
396
|
+
const msgs = threadMessages.messages;
|
|
397
|
+
const latestAssistantTs = msgs
|
|
398
|
+
.filter(m => m && m.role === 'assistant' && m.created_at)
|
|
399
|
+
.reduce((max, m) => Math.max(max, m.created_at), 0);
|
|
400
|
+
|
|
401
|
+
// When we see a newer assistant message than we had before, treat it as the reply.
|
|
402
|
+
if (latestAssistantTs && latestAssistantTs > lastAssistantTs) {
|
|
403
|
+
this.messages = msgs;
|
|
404
|
+
this.UI.content.update(msgs);
|
|
405
|
+
|
|
406
|
+
clearInterval(this.pollingInterval);
|
|
407
|
+
this.pollingInterval = null;
|
|
408
|
+
this.hideThinkingMessage();
|
|
409
|
+
this.UI.textarea.disabled = false;
|
|
410
|
+
this.UI.sendButton.disabled = false;
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (attempts > 60) { // ~2 minutes
|
|
416
|
+
console.warn('Thread polling timeout for assistant reply');
|
|
417
|
+
clearInterval(this.pollingInterval);
|
|
418
|
+
this.pollingInterval = null;
|
|
419
|
+
this.hideThinkingMessage();
|
|
420
|
+
this.UI.textarea.disabled = false;
|
|
421
|
+
this.UI.sendButton.disabled = false;
|
|
422
|
+
alert('Timed out waiting for assistant response.');
|
|
423
|
+
}
|
|
424
|
+
} catch (err) {
|
|
425
|
+
console.error('Polling error (thread messages):', err);
|
|
426
|
+
clearInterval(this.pollingInterval);
|
|
427
|
+
this.pollingInterval = null;
|
|
428
|
+
this.hideThinkingMessage();
|
|
429
|
+
this.UI.textarea.disabled = false;
|
|
430
|
+
this.UI.sendButton.disabled = false;
|
|
431
|
+
alert('Error while checking assistant response.');
|
|
432
|
+
}
|
|
433
|
+
}, 2000);
|
|
434
|
+
}
|
|
435
|
+
showThinkingMessage() {
|
|
436
|
+
const messagesDiv = this.UI.content;
|
|
437
|
+
if (!messagesDiv) return;
|
|
438
|
+
|
|
439
|
+
// Pull assistant thinking text
|
|
440
|
+
const assistant = this.getAssistantInfo(this.selected_assistant_id);
|
|
441
|
+
const thinkingText = assistant?.assistant_thinking_text || 'Assistant is thinking...';
|
|
442
|
+
|
|
443
|
+
const div = document.createElement('div');
|
|
444
|
+
div.className = 'thinking-message';
|
|
445
|
+
div.textContent = thinkingText;
|
|
446
|
+
div.id = 'thinking-message';
|
|
447
|
+
messagesDiv.appendChild(div);
|
|
448
|
+
|
|
449
|
+
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
hideThinkingMessage() {
|
|
453
|
+
const existing = this.shadowRoot.querySelector('#thinking-message');
|
|
454
|
+
if (existing) existing.remove();
|
|
455
|
+
}
|
|
456
|
+
closeChat() {
|
|
457
|
+
// Remove the element
|
|
458
|
+
this.remove();
|
|
459
|
+
|
|
460
|
+
// Clean up from open chat registry if possible
|
|
461
|
+
if (_joe && _joe.Ai && _joe.Ai._openChats) {
|
|
462
|
+
delete _joe.Ai._openChats[this.conversation_id];
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
customElements.define('joe-ai-chatbox', JoeAIChatbox);
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
class JoeObject extends HTMLElement {
|
|
471
|
+
constructor() {
|
|
472
|
+
super();
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
this.object_id = this.getAttribute('object_id');
|
|
476
|
+
this.object = $J.get(this.object_id);
|
|
477
|
+
|
|
478
|
+
}
|
|
479
|
+
connectedCallback() {
|
|
480
|
+
const id = this.getAttribute('object_id');
|
|
481
|
+
var sTemp = $J.schema('business')?.listView?.title||false;
|
|
482
|
+
this.innerHTML = (sTemp)?JOE.propAsFuncOrValue(sTemp,this.object) :`<jo-title>${this.object.name}</jo-title>
|
|
483
|
+
<jo-subtitle>${this.object.info} - ${this.object._id}</jo-subtitle>`;
|
|
484
|
+
this.addEventListener('click', () => {
|
|
485
|
+
// Handle click event here, e.g., open the object in a new tab or show details
|
|
486
|
+
goJoe(_joe.search(this.object._id)[0],{schema:this.object.itemtype})
|
|
487
|
+
//window.open(`/object/${this.object_id}`, '_blank');
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
}
|
|
492
|
+
customElements.define('joe-object', JoeObject);
|
|
493
|
+
|
|
494
|
+
// ---------- Joe AI Widget: embeddable chat for any site ----------
|
|
495
|
+
class JoeAIWidget extends HTMLElement {
|
|
496
|
+
constructor() {
|
|
497
|
+
super();
|
|
498
|
+
this.attachShadow({ mode: 'open' });
|
|
499
|
+
this.endpoint = this.getAttribute('endpoint') || ''; // base URL to JOE; default same origin
|
|
500
|
+
this.conversation_id = this.getAttribute('conversation_id') || null;
|
|
501
|
+
this.assistant_id = this.getAttribute('assistant_id') || null;
|
|
502
|
+
this.ai_assistant_id = this.getAttribute('ai_assistant_id') || null;
|
|
503
|
+
this.user_id = this.getAttribute('user_id') || null;
|
|
504
|
+
this.model = this.getAttribute('model') || null;
|
|
505
|
+
this.assistant_color = this.getAttribute('assistant_color') || null;
|
|
506
|
+
this.user_color = this.getAttribute('user_color') || null;
|
|
507
|
+
// Optional persistence key so widgets inside dynamic layouts (like capp cards)
|
|
508
|
+
// can restore their conversation after re-render or resize.
|
|
509
|
+
this.persist_key =
|
|
510
|
+
this.getAttribute('persist_key') ||
|
|
511
|
+
(this.getAttribute('source') ? ('joe-ai-widget:' + this.getAttribute('source')) : null);
|
|
512
|
+
this.messages = [];
|
|
513
|
+
this._ui = {};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
connectedCallback() {
|
|
517
|
+
this.renderShell();
|
|
518
|
+
// If we don't have an explicit conversation_id but a persisted one exists,
|
|
519
|
+
// restore it before deciding what to load.
|
|
520
|
+
this.restoreFromStorageIfNeeded();
|
|
521
|
+
// Lazy conversation creation:
|
|
522
|
+
// - If a conversation_id is provided, load its history.
|
|
523
|
+
// - Otherwise, do NOT create a conversation until the user actually
|
|
524
|
+
// sends a message. `sendMessage` will call startConversation() on
|
|
525
|
+
// demand when needed.
|
|
526
|
+
if (this.conversation_id) {
|
|
527
|
+
this.loadHistory();
|
|
528
|
+
} else {
|
|
529
|
+
this.setStatus('online');
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
get apiBase() {
|
|
534
|
+
return this.endpoint || '';
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
persistState() {
|
|
538
|
+
if (!this.persist_key || typeof window === 'undefined' || !window.localStorage) return;
|
|
539
|
+
try {
|
|
540
|
+
const payload = {
|
|
541
|
+
conversation_id: this.conversation_id,
|
|
542
|
+
assistant_id: this.assistant_id,
|
|
543
|
+
model: this.model,
|
|
544
|
+
assistant_color: this.assistant_color
|
|
545
|
+
};
|
|
546
|
+
window.localStorage.setItem(this.persist_key, JSON.stringify(payload));
|
|
547
|
+
} catch (_e) { /* ignore storage errors */ }
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
restoreFromStorageIfNeeded() {
|
|
551
|
+
if (this.conversation_id || !this.persist_key || typeof window === 'undefined' || !window.localStorage) return;
|
|
552
|
+
try {
|
|
553
|
+
const raw = window.localStorage.getItem(this.persist_key);
|
|
554
|
+
if (!raw) return;
|
|
555
|
+
const data = JSON.parse(raw);
|
|
556
|
+
if (!data || !data.conversation_id) return;
|
|
557
|
+
this.conversation_id = data.conversation_id;
|
|
558
|
+
this.setAttribute('conversation_id', this.conversation_id);
|
|
559
|
+
this.assistant_id = data.assistant_id || this.assistant_id;
|
|
560
|
+
this.model = data.model || this.model;
|
|
561
|
+
this.assistant_color = data.assistant_color || this.assistant_color;
|
|
562
|
+
} catch (_e) { /* ignore */ }
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
renderShell() {
|
|
566
|
+
const style = document.createElement('style');
|
|
567
|
+
style.textContent = `
|
|
568
|
+
:host {
|
|
569
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
570
|
+
display: block;
|
|
571
|
+
/* Allow the widget to grow/shrink with its container (cards, sidebars, etc.) */
|
|
572
|
+
width: 100%;
|
|
573
|
+
height: 100%;
|
|
574
|
+
max-width: none;
|
|
575
|
+
border: 1px solid #ddd;
|
|
576
|
+
border-radius: 8px;
|
|
577
|
+
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
|
|
578
|
+
overflow: hidden;
|
|
579
|
+
background: #fff;
|
|
580
|
+
}
|
|
581
|
+
.wrapper {
|
|
582
|
+
display: flex;
|
|
583
|
+
flex-direction: column;
|
|
584
|
+
height: 100%;
|
|
585
|
+
}
|
|
586
|
+
.header {
|
|
587
|
+
padding: 8px 12px;
|
|
588
|
+
background: #1f2933;
|
|
589
|
+
color: #f9fafb;
|
|
590
|
+
font-size: 13px;
|
|
591
|
+
display: flex;
|
|
592
|
+
align-items: center;
|
|
593
|
+
justify-content: space-between;
|
|
594
|
+
}
|
|
595
|
+
.title {
|
|
596
|
+
font-weight: 600;
|
|
597
|
+
}
|
|
598
|
+
.status {
|
|
599
|
+
font-size: 11px;
|
|
600
|
+
opacity: 0.8;
|
|
601
|
+
}
|
|
602
|
+
.messages {
|
|
603
|
+
padding: 10px;
|
|
604
|
+
/* Fill remaining space between header and footer */
|
|
605
|
+
flex: 1 1 auto;
|
|
606
|
+
min-height: 0;
|
|
607
|
+
overflow-y: auto;
|
|
608
|
+
background: #f5f7fa;
|
|
609
|
+
font-size: 13px;
|
|
610
|
+
}
|
|
611
|
+
.msg {
|
|
612
|
+
margin-bottom: 8px;
|
|
613
|
+
max-width: 90%;
|
|
614
|
+
clear: both;
|
|
615
|
+
}
|
|
616
|
+
.msg.user {
|
|
617
|
+
text-align: right;
|
|
618
|
+
margin-left: auto;
|
|
619
|
+
}
|
|
620
|
+
.bubble {
|
|
621
|
+
display: inline-block;
|
|
622
|
+
padding: 6px 8px;
|
|
623
|
+
border-radius: 5px;
|
|
624
|
+
line-height: 1.4;
|
|
625
|
+
max-width: 100%;
|
|
626
|
+
overflow-x: auto;
|
|
627
|
+
}
|
|
628
|
+
.user .bubble {
|
|
629
|
+
background: var(--joe-ai-user-bg, #2563eb);
|
|
630
|
+
color: #fff;
|
|
631
|
+
}
|
|
632
|
+
.assistant .bubble {
|
|
633
|
+
background: var(--joe-ai-assistant-bg, #e5e7eb);
|
|
634
|
+
color: #111827;
|
|
635
|
+
}
|
|
636
|
+
.msg.assistant.tools-used .bubble {
|
|
637
|
+
background: #fef3c7;
|
|
638
|
+
color: #92400e;
|
|
639
|
+
font-size: 11px;
|
|
640
|
+
}
|
|
641
|
+
.footer {
|
|
642
|
+
border-top: 1px solid #e5e7eb;
|
|
643
|
+
padding: 6px;
|
|
644
|
+
display: flex;
|
|
645
|
+
gap: 6px;
|
|
646
|
+
align-items: center;
|
|
647
|
+
}
|
|
648
|
+
textarea {
|
|
649
|
+
flex: 1;
|
|
650
|
+
resize: none;
|
|
651
|
+
border-radius: 6px;
|
|
652
|
+
border: 1px solid #d1d5db;
|
|
653
|
+
padding: 6px 8px;
|
|
654
|
+
font-size: 13px;
|
|
655
|
+
min-height: 34px;
|
|
656
|
+
max-height: 80px;
|
|
657
|
+
}
|
|
658
|
+
button {
|
|
659
|
+
border-radius: 6px;
|
|
660
|
+
border: none;
|
|
661
|
+
background: #2563eb;
|
|
662
|
+
color: #fff;
|
|
663
|
+
padding: 6px 10px;
|
|
664
|
+
font-size: 13px;
|
|
665
|
+
cursor: pointer;
|
|
666
|
+
white-space: nowrap;
|
|
667
|
+
}
|
|
668
|
+
button:disabled {
|
|
669
|
+
opacity: 0.6;
|
|
670
|
+
cursor: default;
|
|
671
|
+
}
|
|
672
|
+
`;
|
|
673
|
+
|
|
674
|
+
const wrapper = document.createElement('div');
|
|
675
|
+
wrapper.className = 'wrapper';
|
|
676
|
+
wrapper.innerHTML = `
|
|
677
|
+
<div class="header">
|
|
678
|
+
<div class="title">${this.getAttribute('title') || 'AI Assistant'}</div>
|
|
679
|
+
<div class="status" id="status">connecting…</div>
|
|
680
|
+
</div>
|
|
681
|
+
<div class="messages" id="messages"></div>
|
|
682
|
+
<div class="footer">
|
|
683
|
+
<textarea id="input" placeholder="${this.getAttribute('placeholder') || 'Ask me anything…'}"></textarea>
|
|
684
|
+
<button id="send">Send</button>
|
|
685
|
+
</div>
|
|
686
|
+
`;
|
|
687
|
+
|
|
688
|
+
this.shadowRoot.innerHTML = '';
|
|
689
|
+
this.shadowRoot.appendChild(style);
|
|
690
|
+
this.shadowRoot.appendChild(wrapper);
|
|
691
|
+
|
|
692
|
+
this._ui.messages = this.shadowRoot.getElementById('messages');
|
|
693
|
+
this._ui.status = this.shadowRoot.getElementById('status');
|
|
694
|
+
this._ui.input = this.shadowRoot.getElementById('input');
|
|
695
|
+
this._ui.send = this.shadowRoot.getElementById('send');
|
|
696
|
+
|
|
697
|
+
this._ui.send.addEventListener('click', () => this.sendMessage());
|
|
698
|
+
this._ui.input.addEventListener('keydown', (e) => {
|
|
699
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
700
|
+
e.preventDefault();
|
|
701
|
+
this.sendMessage();
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
this.applyThemeColors();
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
applyThemeColors() {
|
|
708
|
+
if (!this.shadowRoot || !this.shadowRoot.host) return;
|
|
709
|
+
const host = this.shadowRoot.host;
|
|
710
|
+
if (this.assistant_color) {
|
|
711
|
+
host.style.setProperty('--joe-ai-assistant-bg', this.assistant_color);
|
|
712
|
+
}
|
|
713
|
+
if (this.user_color) {
|
|
714
|
+
host.style.setProperty('--joe-ai-user-bg', this.user_color);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
setStatus(text) {
|
|
719
|
+
if (!this._ui.status) return;
|
|
720
|
+
const cid = this.conversation_id || '';
|
|
721
|
+
// Show conversation id when available, e.g. "cuid123 - online"
|
|
722
|
+
this._ui.status.textContent = cid ? (cid + ' - ' + text) : text;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
renderMessages() {
|
|
726
|
+
if (!this._ui.messages) return;
|
|
727
|
+
this._ui.messages.innerHTML = (this.messages || []).map(m => {
|
|
728
|
+
const role = m.role || 'assistant';
|
|
729
|
+
const extra = m.meta === 'tools_used' ? ' tools-used' : '';
|
|
730
|
+
const html = renderMarkdownSafe(m.content || '');
|
|
731
|
+
return `<div class="msg ${role}${extra}"><div class="bubble">${html}</div></div>`;
|
|
732
|
+
}).join('');
|
|
733
|
+
this._ui.messages.scrollTop = this._ui.messages.scrollHeight;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Resolve the logical user for this widget instance.
|
|
737
|
+
// Priority:
|
|
738
|
+
// 1) Explicit user_id attribute on the element (fetch /API/item/user/_id/:id)
|
|
739
|
+
// 2) Page-level window._aiWidgetUser (used by ai-widget-test.html)
|
|
740
|
+
// 3) null (caller can still force a user_color theme)
|
|
741
|
+
async resolveUser() {
|
|
742
|
+
if (this._resolvedUser) {
|
|
743
|
+
return this._resolvedUser;
|
|
744
|
+
}
|
|
745
|
+
// 1) If the hosting page passed a user_id attribute, look up that user via JOE.
|
|
746
|
+
if (this.user_id) {
|
|
747
|
+
try {
|
|
748
|
+
const res = await fetch(
|
|
749
|
+
this.apiBase + '/API/item/user/_id/' + encodeURIComponent(this.user_id),
|
|
750
|
+
{ credentials: 'include' }
|
|
751
|
+
);
|
|
752
|
+
const data = await res.json();
|
|
753
|
+
const u = (data && data.item) || null;
|
|
754
|
+
if (u && u._id) {
|
|
755
|
+
this._resolvedUser = u;
|
|
756
|
+
return u;
|
|
757
|
+
}
|
|
758
|
+
} catch (e) {
|
|
759
|
+
console.error('JoeAIWidget.resolveUser: failed to load user by id', this.user_id, e);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
// 2) Fall back to a page-global user (ai-widget-test.html populates this).
|
|
763
|
+
if (typeof window !== 'undefined' && window._aiWidgetUser) {
|
|
764
|
+
this._resolvedUser = window._aiWidgetUser;
|
|
765
|
+
return this._resolvedUser;
|
|
766
|
+
}
|
|
767
|
+
return null;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
async startConversation() {
|
|
771
|
+
try {
|
|
772
|
+
this.setStatus('connecting…');
|
|
773
|
+
const payload = {
|
|
774
|
+
model: this.model || undefined,
|
|
775
|
+
ai_assistant_id: this.getAttribute('ai_assistant_id') || undefined,
|
|
776
|
+
source: this.getAttribute('source') || 'widget'
|
|
777
|
+
};
|
|
778
|
+
// Resolve the effective user for this widget and pass id/name/color
|
|
779
|
+
// explicitly to the server. This works for:
|
|
780
|
+
// - ai-widget-test.html (which sets window._aiWidgetUser)
|
|
781
|
+
// - JOE pages or external sites that pass a user_id attribute
|
|
782
|
+
try {
|
|
783
|
+
const globalUser = await this.resolveUser();
|
|
784
|
+
if (globalUser) {
|
|
785
|
+
payload.user_id = globalUser._id;
|
|
786
|
+
payload.user_name = globalUser.fullname || globalUser.name;
|
|
787
|
+
if (globalUser.color) { payload.user_color = globalUser.color; }
|
|
788
|
+
} else if (this.user_color) {
|
|
789
|
+
payload.user_color = this.user_color;
|
|
790
|
+
}
|
|
791
|
+
} catch (_e) { /* ignore */ }
|
|
792
|
+
const resp = await fetch(this.apiBase + '/API/plugin/chatgpt/widgetStart', {
|
|
793
|
+
method: 'POST',
|
|
794
|
+
headers: { 'Content-Type': 'application/json' },
|
|
795
|
+
body: JSON.stringify(payload)
|
|
796
|
+
}).then(r => r.json());
|
|
797
|
+
|
|
798
|
+
if (!resp || resp.success !== true) {
|
|
799
|
+
const msg = (resp && (resp.error || resp.message)) || 'Failed to start conversation';
|
|
800
|
+
this.setStatus('error: ' + msg);
|
|
801
|
+
this.messages.push({
|
|
802
|
+
role:'assistant',
|
|
803
|
+
content:'[Error starting conversation: '+msg+']',
|
|
804
|
+
created_at:new Date().toISOString()
|
|
805
|
+
});
|
|
806
|
+
this.renderMessages();
|
|
807
|
+
console.error('widgetStart error', { payload, response: resp });
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
this.conversation_id = resp.conversation_id;
|
|
811
|
+
this.setAttribute('conversation_id', this.conversation_id);
|
|
812
|
+
this.assistant_id = resp.assistant_id || this.assistant_id;
|
|
813
|
+
this.model = resp.model || this.model;
|
|
814
|
+
this.assistant_color = resp.assistant_color || this.assistant_color;
|
|
815
|
+
this.applyThemeColors();
|
|
816
|
+
this.messages = [];
|
|
817
|
+
this.renderMessages();
|
|
818
|
+
this.setStatus('online');
|
|
819
|
+
this.persistState();
|
|
820
|
+
} catch (e) {
|
|
821
|
+
console.error('widgetStart exception', e);
|
|
822
|
+
this.setStatus('error');
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
async loadHistory() {
|
|
827
|
+
try {
|
|
828
|
+
this.setStatus('loading…');
|
|
829
|
+
const resp = await fetch(
|
|
830
|
+
this.apiBase + '/API/plugin/chatgpt/widgetHistory?conversation_id=' +
|
|
831
|
+
encodeURIComponent(this.conversation_id)
|
|
832
|
+
).then(r => r.json());
|
|
833
|
+
if (!resp || resp.success !== true) {
|
|
834
|
+
console.warn('widgetHistory non-success response', resp);
|
|
835
|
+
this.setStatus('online');
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
this.assistant_id = resp.assistant_id || this.assistant_id;
|
|
839
|
+
this.model = resp.model || this.model;
|
|
840
|
+
this.assistant_color = resp.assistant_color || this.assistant_color;
|
|
841
|
+
this.applyThemeColors();
|
|
842
|
+
this.messages = resp.messages || [];
|
|
843
|
+
this.renderMessages();
|
|
844
|
+
this.setStatus('online');
|
|
845
|
+
this.persistState();
|
|
846
|
+
} catch (e) {
|
|
847
|
+
console.error('widgetHistory exception', e);
|
|
848
|
+
this.setStatus('online');
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
async sendMessage() {
|
|
853
|
+
const input = this._ui.input;
|
|
854
|
+
if (!input) return;
|
|
855
|
+
const text = input.value.trim();
|
|
856
|
+
if (!text) return;
|
|
857
|
+
if (!this.conversation_id) {
|
|
858
|
+
await this.startConversation();
|
|
859
|
+
if (!this.conversation_id) return;
|
|
860
|
+
}
|
|
861
|
+
input.value = '';
|
|
862
|
+
this._ui.send.disabled = true;
|
|
863
|
+
|
|
864
|
+
const userMsg = { role: 'user', content: text, created_at: new Date().toISOString() };
|
|
865
|
+
this.messages.push(userMsg);
|
|
866
|
+
this.renderMessages();
|
|
867
|
+
this.setStatus('thinking…');
|
|
868
|
+
|
|
869
|
+
try {
|
|
870
|
+
const payload = {
|
|
871
|
+
conversation_id: this.conversation_id,
|
|
872
|
+
content: text,
|
|
873
|
+
role: 'user',
|
|
874
|
+
assistant_id: this.assistant_id || undefined,
|
|
875
|
+
model: this.model || undefined
|
|
876
|
+
};
|
|
877
|
+
try {
|
|
878
|
+
const globalUser = await this.resolveUser();
|
|
879
|
+
if (globalUser) {
|
|
880
|
+
payload.user_id = globalUser._id;
|
|
881
|
+
payload.user_name = globalUser.fullname || globalUser.name;
|
|
882
|
+
if (globalUser.color) { payload.user_color = globalUser.color; }
|
|
883
|
+
} else if (this.user_color) {
|
|
884
|
+
payload.user_color = this.user_color;
|
|
885
|
+
}
|
|
886
|
+
} catch (_e) { /* ignore */ }
|
|
887
|
+
const resp = await fetch(this.apiBase + '/API/plugin/chatgpt/widgetMessage', {
|
|
888
|
+
method: 'POST',
|
|
889
|
+
headers: { 'Content-Type': 'application/json' },
|
|
890
|
+
body: JSON.stringify(payload)
|
|
891
|
+
}).then(r => r.json());
|
|
892
|
+
|
|
893
|
+
if (!resp || resp.success !== true) {
|
|
894
|
+
const msg = (resp && (resp.error || resp.message)) || 'Failed to send message';
|
|
895
|
+
console.error('widgetMessage error', { payload, response: resp });
|
|
896
|
+
this.messages.push({ role:'assistant', content:'[Error: '+msg+']', created_at:new Date().toISOString() });
|
|
897
|
+
this.renderMessages();
|
|
898
|
+
this.setStatus('error: ' + msg);
|
|
899
|
+
} else {
|
|
900
|
+
this.assistant_id = resp.assistant_id || this.assistant_id;
|
|
901
|
+
this.model = resp.model || this.model;
|
|
902
|
+
this.assistant_color = resp.assistant_color || this.assistant_color;
|
|
903
|
+
this.applyThemeColors();
|
|
904
|
+
this.messages = resp.messages || this.messages;
|
|
905
|
+
this.renderMessages();
|
|
906
|
+
this.setStatus('online');
|
|
907
|
+
this.persistState();
|
|
908
|
+
}
|
|
909
|
+
} catch (e) {
|
|
910
|
+
console.error('widgetMessage exception', e);
|
|
911
|
+
this.setStatus('error');
|
|
912
|
+
} finally {
|
|
913
|
+
this._ui.send.disabled = false;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
if (!customElements.get('joe-ai-widget')) {
|
|
919
|
+
customElements.define('joe-ai-widget', JoeAIWidget);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// ---------- Assistant picker: small selector component for joe-ai-widget ----------
|
|
923
|
+
class JoeAIAssistantPicker extends HTMLElement {
|
|
924
|
+
constructor() {
|
|
925
|
+
super();
|
|
926
|
+
this.attachShadow({ mode: 'open' });
|
|
927
|
+
this._ui = {};
|
|
928
|
+
this._assistants = [];
|
|
929
|
+
this._defaultId = null;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
connectedCallback() {
|
|
933
|
+
this.renderShell();
|
|
934
|
+
this.init();
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
get widget() {
|
|
938
|
+
const targetId = this.getAttribute('for_widget');
|
|
939
|
+
if (targetId) {
|
|
940
|
+
return document.getElementById(targetId);
|
|
941
|
+
}
|
|
942
|
+
// Fallback: nearest joe-ai-widget in the same card/container
|
|
943
|
+
return this.closest('capp-card,div')?.querySelector('joe-ai-widget') || null;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
renderShell() {
|
|
947
|
+
const style = document.createElement('style');
|
|
948
|
+
style.textContent = `
|
|
949
|
+
:host {
|
|
950
|
+
display: block;
|
|
951
|
+
margin-bottom: 6px;
|
|
952
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
953
|
+
font-size: 12px;
|
|
954
|
+
}
|
|
955
|
+
.row {
|
|
956
|
+
display: flex;
|
|
957
|
+
align-items: center;
|
|
958
|
+
justify-content: space-between;
|
|
959
|
+
gap: 6px;
|
|
960
|
+
}
|
|
961
|
+
label {
|
|
962
|
+
color: #374151;
|
|
963
|
+
}
|
|
964
|
+
select {
|
|
965
|
+
margin-left: 4px;
|
|
966
|
+
padding: 2px 4px;
|
|
967
|
+
font-size: 12px;
|
|
968
|
+
}
|
|
969
|
+
.hint {
|
|
970
|
+
font-size: 11px;
|
|
971
|
+
color: #6b7280;
|
|
972
|
+
margin-left: 4px;
|
|
973
|
+
}
|
|
974
|
+
`;
|
|
975
|
+
const wrapper = document.createElement('div');
|
|
976
|
+
wrapper.className = 'row';
|
|
977
|
+
wrapper.innerHTML = `
|
|
978
|
+
<div>
|
|
979
|
+
<label>Assistant:</label>
|
|
980
|
+
<select id="assistant-select"></select>
|
|
981
|
+
<span id="assistant-hint" class="hint"></span>
|
|
982
|
+
</div>
|
|
983
|
+
<div>
|
|
984
|
+
<button id="new-convo" style="font-size:11px;padding:2px 8px;">New conversation</button>
|
|
985
|
+
</div>
|
|
986
|
+
`;
|
|
987
|
+
this.shadowRoot.innerHTML = '';
|
|
988
|
+
this.shadowRoot.appendChild(style);
|
|
989
|
+
this.shadowRoot.appendChild(wrapper);
|
|
990
|
+
this._ui.select = this.shadowRoot.getElementById('assistant-select');
|
|
991
|
+
this._ui.hint = this.shadowRoot.getElementById('assistant-hint');
|
|
992
|
+
this._ui.newConvo = this.shadowRoot.getElementById('new-convo');
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
async init() {
|
|
996
|
+
const select = this._ui.select;
|
|
997
|
+
const hint = this._ui.hint;
|
|
998
|
+
const widget = this.widget;
|
|
999
|
+
if (!select || !widget) return;
|
|
1000
|
+
|
|
1001
|
+
try {
|
|
1002
|
+
const [assistantsResp, settingsResp] = await Promise.all([
|
|
1003
|
+
fetch('/API/item/ai_assistant', { credentials: 'include' }).then(r => r.json()),
|
|
1004
|
+
fetch('/API/item/setting', { credentials: 'include' }).then(r => r.json())
|
|
1005
|
+
]);
|
|
1006
|
+
this._assistants = Array.isArray(assistantsResp.item) ? assistantsResp.item : [];
|
|
1007
|
+
const settings = Array.isArray(settingsResp.item) ? settingsResp.item : [];
|
|
1008
|
+
const def = settings.find(s => s && s.name === 'DEFAULT_AI_ASSISTANT');
|
|
1009
|
+
this._defaultId = def && def.value;
|
|
1010
|
+
} catch (e) {
|
|
1011
|
+
console.error('[joe-ai] AssistantPicker init error', e);
|
|
1012
|
+
this._assistants = [];
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
select.innerHTML = '';
|
|
1016
|
+
const noneOption = document.createElement('option');
|
|
1017
|
+
noneOption.value = '';
|
|
1018
|
+
noneOption.textContent = 'None (model only)';
|
|
1019
|
+
select.appendChild(noneOption);
|
|
1020
|
+
|
|
1021
|
+
this._assistants.forEach(a => {
|
|
1022
|
+
const opt = document.createElement('option');
|
|
1023
|
+
opt.value = a._id;
|
|
1024
|
+
opt.textContent = a.name || a.title || a._id;
|
|
1025
|
+
if (a._id === this._defaultId) {
|
|
1026
|
+
opt.textContent += ' (default)';
|
|
1027
|
+
}
|
|
1028
|
+
select.appendChild(opt);
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
let initialId = '';
|
|
1032
|
+
if (widget.getAttribute('ai_assistant_id')) {
|
|
1033
|
+
initialId = widget.getAttribute('ai_assistant_id');
|
|
1034
|
+
hint && (hint.textContent = 'Using assistant from widget attribute.');
|
|
1035
|
+
} else if (this._defaultId && this._assistants.some(a => a && a._id === this._defaultId)) {
|
|
1036
|
+
initialId = this._defaultId;
|
|
1037
|
+
hint && (hint.textContent = 'Using DEFAULT_AI_ASSISTANT.');
|
|
1038
|
+
} else {
|
|
1039
|
+
hint && (hint.textContent = this._assistants.length
|
|
1040
|
+
? 'No default; using model only.'
|
|
1041
|
+
: 'No assistants defined; using model only.');
|
|
1042
|
+
}
|
|
1043
|
+
select.value = initialId;
|
|
1044
|
+
this.applyAssistantToWidget(widget, initialId);
|
|
1045
|
+
|
|
1046
|
+
select.addEventListener('change', () => {
|
|
1047
|
+
this.applyAssistantToWidget(widget, select.value);
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
if (this._ui.newConvo) {
|
|
1051
|
+
this._ui.newConvo.addEventListener('click', () => {
|
|
1052
|
+
this.startNewConversation(widget);
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
startNewConversation(widget) {
|
|
1058
|
+
try {
|
|
1059
|
+
if (!widget) return;
|
|
1060
|
+
// Clear persisted state and conversation id so a fresh widgetStart will happen
|
|
1061
|
+
if (widget.persist_key && typeof window !== 'undefined' && window.localStorage) {
|
|
1062
|
+
try { window.localStorage.removeItem(widget.persist_key); } catch (_e) { /* ignore */ }
|
|
1063
|
+
}
|
|
1064
|
+
widget.removeAttribute('conversation_id');
|
|
1065
|
+
widget.conversation_id = null;
|
|
1066
|
+
if (Array.isArray(widget.messages)) {
|
|
1067
|
+
widget.messages = [];
|
|
1068
|
+
}
|
|
1069
|
+
if (typeof widget.renderMessages === 'function') {
|
|
1070
|
+
widget.renderMessages();
|
|
1071
|
+
}
|
|
1072
|
+
if (typeof widget.setStatus === 'function') {
|
|
1073
|
+
widget.setStatus('online');
|
|
1074
|
+
}
|
|
1075
|
+
} catch (e) {
|
|
1076
|
+
console.error('[joe-ai] error starting new conversation from picker', e);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
applyAssistantToWidget(widget, assistantId) {
|
|
1081
|
+
try {
|
|
1082
|
+
if (!widget) return;
|
|
1083
|
+
const val = assistantId || '';
|
|
1084
|
+
if (val) {
|
|
1085
|
+
widget.setAttribute('ai_assistant_id', val);
|
|
1086
|
+
} else {
|
|
1087
|
+
widget.removeAttribute('ai_assistant_id');
|
|
1088
|
+
}
|
|
1089
|
+
// Reset conversation and clear persisted state so a new chat starts
|
|
1090
|
+
if (widget.persist_key && typeof window !== 'undefined' && window.localStorage) {
|
|
1091
|
+
try { window.localStorage.removeItem(widget.persist_key); } catch (_e) { /* ignore */ }
|
|
1092
|
+
}
|
|
1093
|
+
widget.removeAttribute('conversation_id');
|
|
1094
|
+
widget.conversation_id = null;
|
|
1095
|
+
if (typeof widget.setStatus === 'function') {
|
|
1096
|
+
widget.setStatus('online');
|
|
1097
|
+
}
|
|
1098
|
+
} catch (e) {
|
|
1099
|
+
console.error('[joe-ai] error applying assistant selection', e);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
if (!customElements.get('joe-ai-assistant-picker')) {
|
|
1105
|
+
customElements.define('joe-ai-assistant-picker', JoeAIAssistantPicker);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// ---------- Conversation list: clickable ai_widget_conversation list ----------
|
|
1109
|
+
class JoeAIConversationList extends HTMLElement {
|
|
1110
|
+
constructor() {
|
|
1111
|
+
super();
|
|
1112
|
+
this.attachShadow({ mode: 'open' });
|
|
1113
|
+
this._ui = {};
|
|
1114
|
+
this._assistants = [];
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
connectedCallback() {
|
|
1118
|
+
this.renderShell();
|
|
1119
|
+
this.refreshConversations();
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
get widget() {
|
|
1123
|
+
const targetId = this.getAttribute('for_widget');
|
|
1124
|
+
if (targetId) {
|
|
1125
|
+
return document.getElementById(targetId);
|
|
1126
|
+
}
|
|
1127
|
+
return this.closest('capp-card,div')?.querySelector('joe-ai-widget') || null;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
renderShell() {
|
|
1131
|
+
const style = document.createElement('style');
|
|
1132
|
+
style.textContent = `
|
|
1133
|
+
:host {
|
|
1134
|
+
display: block;
|
|
1135
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
1136
|
+
font-size: 12px;
|
|
1137
|
+
}
|
|
1138
|
+
.container {
|
|
1139
|
+
display: flex;
|
|
1140
|
+
flex-direction: column;
|
|
1141
|
+
height: 100%;
|
|
1142
|
+
}
|
|
1143
|
+
.header {
|
|
1144
|
+
display: flex;
|
|
1145
|
+
align-items: center;
|
|
1146
|
+
justify-content: space-between;
|
|
1147
|
+
margin-bottom: 4px;
|
|
1148
|
+
font-weight: 600;
|
|
1149
|
+
}
|
|
1150
|
+
.status {
|
|
1151
|
+
font-size: 11px;
|
|
1152
|
+
margin-bottom: 4px;
|
|
1153
|
+
}
|
|
1154
|
+
.row {
|
|
1155
|
+
margin-bottom: 2px;
|
|
1156
|
+
}
|
|
1157
|
+
.row.chips {
|
|
1158
|
+
display: flex;
|
|
1159
|
+
gap: 4px;
|
|
1160
|
+
flex-wrap: wrap;
|
|
1161
|
+
}
|
|
1162
|
+
.chip {
|
|
1163
|
+
display: inline-block;
|
|
1164
|
+
padding: 1px 6px;
|
|
1165
|
+
border-radius: 999px;
|
|
1166
|
+
font-size: 10px;
|
|
1167
|
+
line-height: 1.4;
|
|
1168
|
+
white-space: nowrap;
|
|
1169
|
+
}
|
|
1170
|
+
.row.link a {
|
|
1171
|
+
font-size: 11px;
|
|
1172
|
+
}
|
|
1173
|
+
ul {
|
|
1174
|
+
list-style: none;
|
|
1175
|
+
padding: 0;
|
|
1176
|
+
margin: 0;
|
|
1177
|
+
flex: 1 1 auto;
|
|
1178
|
+
overflow-y: auto;
|
|
1179
|
+
}
|
|
1180
|
+
li {
|
|
1181
|
+
cursor: pointer;
|
|
1182
|
+
padding: 3px 0;
|
|
1183
|
+
border-bottom: 1px solid #e5e7eb;
|
|
1184
|
+
}
|
|
1185
|
+
li:hover {
|
|
1186
|
+
background: #f9fafb;
|
|
1187
|
+
}
|
|
1188
|
+
`;
|
|
1189
|
+
const wrapper = document.createElement('div');
|
|
1190
|
+
wrapper.className = 'container';
|
|
1191
|
+
wrapper.innerHTML = `
|
|
1192
|
+
<div class="header">
|
|
1193
|
+
<span>Widget Conversations</span>
|
|
1194
|
+
<button id="refresh" style="font-size:11px;padding:2px 6px;">Refresh</button>
|
|
1195
|
+
</div>
|
|
1196
|
+
<div id="status" class="status"></div>
|
|
1197
|
+
<ul id="list"></ul>
|
|
1198
|
+
`;
|
|
1199
|
+
this.shadowRoot.innerHTML = '';
|
|
1200
|
+
this.shadowRoot.appendChild(style);
|
|
1201
|
+
this.shadowRoot.appendChild(wrapper);
|
|
1202
|
+
this._ui.status = this.shadowRoot.getElementById('status');
|
|
1203
|
+
this._ui.list = this.shadowRoot.getElementById('list');
|
|
1204
|
+
this._ui.refresh = this.shadowRoot.getElementById('refresh');
|
|
1205
|
+
this._ui.refresh.addEventListener('click', () => this.refreshConversations());
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
async refreshConversations() {
|
|
1209
|
+
const statusEl = this._ui.status;
|
|
1210
|
+
const listEl = this._ui.list;
|
|
1211
|
+
if (!statusEl || !listEl) return;
|
|
1212
|
+
statusEl.textContent = 'Loading conversations...';
|
|
1213
|
+
listEl.innerHTML = '';
|
|
1214
|
+
try {
|
|
1215
|
+
// Load assistants once for name/color lookup
|
|
1216
|
+
if (!this._assistants || !this._assistants.length) {
|
|
1217
|
+
try {
|
|
1218
|
+
const aRes = await fetch('/API/item/ai_assistant', { credentials: 'include' });
|
|
1219
|
+
const aData = await aRes.json();
|
|
1220
|
+
this._assistants = Array.isArray(aData.item) ? aData.item : [];
|
|
1221
|
+
} catch (_e) {
|
|
1222
|
+
this._assistants = [];
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
const res = await fetch('/API/item/ai_widget_conversation', { credentials: 'include' });
|
|
1227
|
+
const data = await res.json();
|
|
1228
|
+
let items = Array.isArray(data.item) ? data.item : [];
|
|
1229
|
+
const allItems = items.slice();
|
|
1230
|
+
const sourceFilter = this.getAttribute('source');
|
|
1231
|
+
let filtered = false;
|
|
1232
|
+
if (sourceFilter) {
|
|
1233
|
+
items = items.filter(c => c.source === sourceFilter);
|
|
1234
|
+
filtered = true;
|
|
1235
|
+
}
|
|
1236
|
+
if (!items.length) {
|
|
1237
|
+
if (filtered && allItems.length) {
|
|
1238
|
+
// Fallback: no conversations for this source; show all instead.
|
|
1239
|
+
items = allItems;
|
|
1240
|
+
statusEl.textContent = 'No conversations for source "' + sourceFilter + '". Showing all widget conversations.';
|
|
1241
|
+
} else {
|
|
1242
|
+
statusEl.textContent = 'No widget conversations found.';
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
items.sort((a, b) => {
|
|
1247
|
+
const da = a.last_message_at || a.joeUpdated || a.created || '';
|
|
1248
|
+
const db = b.last_message_at || b.joeUpdated || b.created || '';
|
|
1249
|
+
return db.localeCompare(da);
|
|
1250
|
+
});
|
|
1251
|
+
statusEl.textContent = 'Click a conversation to resume it.';
|
|
1252
|
+
items.forEach(c => {
|
|
1253
|
+
const li = document.createElement('li');
|
|
1254
|
+
li.dataset.id = c._id;
|
|
1255
|
+
|
|
1256
|
+
const ts = c.last_message_at || c.joeUpdated || c.created || '';
|
|
1257
|
+
const prettyTs = (typeof _joe !== 'undefined' && _joe.Utils && typeof _joe.Utils.prettyPrintDTS === 'function')
|
|
1258
|
+
? _joe.Utils.prettyPrintDTS(ts)
|
|
1259
|
+
: ts;
|
|
1260
|
+
|
|
1261
|
+
// 1. Conversation title: name or pretty date-time
|
|
1262
|
+
const title = c.name || prettyTs || c._id;
|
|
1263
|
+
|
|
1264
|
+
// Helper to choose readable foreground for a hex bg
|
|
1265
|
+
function textColorForBg(hex) {
|
|
1266
|
+
if (!hex || typeof hex !== 'string' || !/^#?[0-9a-fA-F]{6}$/.test(hex)) return '#000';
|
|
1267
|
+
const h = hex[0] === '#' ? hex.slice(1) : hex;
|
|
1268
|
+
const n = parseInt(h, 16);
|
|
1269
|
+
const r = (n >> 16) & 0xff;
|
|
1270
|
+
const g = (n >> 8) & 0xff;
|
|
1271
|
+
const b = n & 0xff;
|
|
1272
|
+
const luminance = r * 0.299 + g * 0.587 + b * 0.114;
|
|
1273
|
+
return luminance > 186 ? '#000' : '#fff';
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// 2. User and assistant colored chiclets
|
|
1277
|
+
const userLabel = c.user_name || c.user || '';
|
|
1278
|
+
const userColor = c.user_color || '';
|
|
1279
|
+
let userChip = '';
|
|
1280
|
+
if (userLabel) {
|
|
1281
|
+
const bg = userColor || '#4b5563';
|
|
1282
|
+
const fg = textColorForBg(bg);
|
|
1283
|
+
userChip = `<span class="chip user" style="background:${bg};color:${fg};">${userLabel}</span>`;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
let asstName = '';
|
|
1287
|
+
let asstColor = '';
|
|
1288
|
+
const asstId = c.assistant;
|
|
1289
|
+
const asstOpenAIId = c.assistant_id;
|
|
1290
|
+
if (this._assistants && this._assistants.length) {
|
|
1291
|
+
const asst = this._assistants.find(a =>
|
|
1292
|
+
(asstId && a && a._id === asstId) ||
|
|
1293
|
+
(asstOpenAIId && a && a.assistant_id === asstOpenAIId)
|
|
1294
|
+
);
|
|
1295
|
+
if (asst) {
|
|
1296
|
+
asstName = asst.name || asst.title || asst.assistant_id || asst._id || 'Assistant';
|
|
1297
|
+
asstColor = asst.assistant_color || asst.color || c.assistant_color || '';
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
if (!asstName && (asstOpenAIId || asstId)) {
|
|
1301
|
+
asstName = asstOpenAIId || asstId;
|
|
1302
|
+
asstColor = c.assistant_color || '';
|
|
1303
|
+
}
|
|
1304
|
+
let asstChip = '';
|
|
1305
|
+
if (asstName) {
|
|
1306
|
+
const bg = asstColor || '#2563eb';
|
|
1307
|
+
const fg = textColorForBg(bg);
|
|
1308
|
+
asstChip = `<span class="chip assistant" style="background:${bg};color:${fg};">${asstName}</span>`;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// 3. Source and # of messages
|
|
1312
|
+
const msgCount = Array.isArray(c.messages) ? c.messages.length : 0;
|
|
1313
|
+
const src = c.source || '';
|
|
1314
|
+
const metaParts = [];
|
|
1315
|
+
if (src) metaParts.push(src);
|
|
1316
|
+
metaParts.push(msgCount + ' msgs');
|
|
1317
|
+
const metaLine = metaParts.join(' · ');
|
|
1318
|
+
|
|
1319
|
+
// 4. Link to the widget conversation in JOE
|
|
1320
|
+
let href = '#/ai_widget_conversation/' + encodeURIComponent(c._id);
|
|
1321
|
+
try {
|
|
1322
|
+
if (window && window.location && typeof window.location.href === 'string') {
|
|
1323
|
+
href = window.location.href.replace(window.location.hash, href);
|
|
1324
|
+
}
|
|
1325
|
+
} catch (_e) { /* ignore */ }
|
|
1326
|
+
|
|
1327
|
+
li.innerHTML = ''
|
|
1328
|
+
+ `<div class="row title">${title}</div>`
|
|
1329
|
+
+ `<div class="row chips">${userChip}${asstChip}</div>`
|
|
1330
|
+
+ `<div class="row meta">${metaLine}</div>`
|
|
1331
|
+
+ `<div class="row link"><a href="${href}" target="_blank" rel="noopener" onclick="event.stopPropagation();">open in JOE</a></div>`;
|
|
1332
|
+
|
|
1333
|
+
li.addEventListener('click', () => {
|
|
1334
|
+
this.applyConversationToWidget(this.widget, c._id);
|
|
1335
|
+
});
|
|
1336
|
+
listEl.appendChild(li);
|
|
1337
|
+
});
|
|
1338
|
+
} catch (e) {
|
|
1339
|
+
console.error('[joe-ai] error loading widget conversations', e);
|
|
1340
|
+
statusEl.textContent = 'Error loading conversations; see console.';
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
applyConversationToWidget(widget, conversationId) {
|
|
1345
|
+
if (!widget || !conversationId) return;
|
|
1346
|
+
widget.setAttribute('conversation_id', conversationId);
|
|
1347
|
+
widget.conversation_id = conversationId;
|
|
1348
|
+
if (typeof widget.loadHistory === 'function') {
|
|
1349
|
+
widget.loadHistory();
|
|
1350
|
+
} else if (widget.connectedCallback) {
|
|
1351
|
+
widget.connectedCallback();
|
|
1352
|
+
}
|
|
1353
|
+
if (typeof widget.persistState === 'function') {
|
|
1354
|
+
widget.persistState();
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
if (!customElements.get('joe-ai-conversation-list')) {
|
|
1360
|
+
customElements.define('joe-ai-conversation-list', JoeAIConversationList);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
//**YES**
|
|
1365
|
+
Ai.spawnChatHelper = async function(object_id,user_id=_joe.User._id,conversation_id) {
|
|
1366
|
+
//if not conversation_id, create a new one
|
|
1367
|
+
let convo_id = conversation_id;
|
|
1368
|
+
var newChat = false;
|
|
1369
|
+
if(!convo_id){
|
|
1370
|
+
const response = await fetch('/API/plugin/chatgpt-assistants/createConversation', {
|
|
1371
|
+
method: 'POST',
|
|
1372
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1373
|
+
body: JSON.stringify({
|
|
1374
|
+
object_id,
|
|
1375
|
+
user_id
|
|
1376
|
+
})
|
|
1377
|
+
}).then(res => res.json());
|
|
1378
|
+
convo_id = response?.conversation?._id;
|
|
1379
|
+
newChat = true;
|
|
1380
|
+
if(response.error){
|
|
1381
|
+
console.error('❌ Failed to create conversation:', response.error);
|
|
1382
|
+
alert('Failed to create conversation: '+response.message);
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
await _joe.Ai.spawnContextualChat(convo_id,{object_id,newChat});
|
|
1388
|
+
}
|
|
1389
|
+
Ai.getDefaultAssistant = function() {
|
|
1390
|
+
|
|
1391
|
+
Ai.default_ai = Ai.default_ai ||_joe.Data.setting.where({name:'DEFAULT_AI_ASSISTANT'})[0]||false;
|
|
1392
|
+
if(!Ai.default_ai){alert('No default AI assistant found');}
|
|
1393
|
+
return Ai.default_ai;
|
|
1394
|
+
}
|
|
1395
|
+
// ========== HELPERS ==========
|
|
1396
|
+
Ai.spawnContextualChat = async function(conversationId, options = {}) {
|
|
1397
|
+
if (!conversationId) {
|
|
1398
|
+
console.warn("Missing conversation ID for chat spawn.");
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
Ai.getDefaultAssistant();
|
|
1402
|
+
|
|
1403
|
+
// 1. Check if chat already open
|
|
1404
|
+
if (Ai._openChats[conversationId]) {
|
|
1405
|
+
console.log("Chatbox already open for", conversationId);
|
|
1406
|
+
Ai._openChats[conversationId].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
try {
|
|
1411
|
+
const flattened = _joe.Object.flatten(options.object_id);
|
|
1412
|
+
if (options.newChat) {
|
|
1413
|
+
// 2. Prepare context
|
|
1414
|
+
|
|
1415
|
+
const contextInstructions = _joe.Ai.generateContextInstructions(flattened,options.object_id);
|
|
1416
|
+
|
|
1417
|
+
// 3. Inject context into backend
|
|
1418
|
+
const contextResult = await fetch('/API/plugin/chatgpt-assistants/addMessage', {
|
|
1419
|
+
method: 'POST',
|
|
1420
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1421
|
+
body: JSON.stringify({
|
|
1422
|
+
conversation_id: conversationId,
|
|
1423
|
+
role: 'JOE',
|
|
1424
|
+
content: contextInstructions,
|
|
1425
|
+
assistant_id: Ai.default_ai.value,
|
|
1426
|
+
object_id: options.object_id
|
|
1427
|
+
})
|
|
1428
|
+
}).then(res => res.json());
|
|
1429
|
+
|
|
1430
|
+
if (!contextResult || contextResult.error) {
|
|
1431
|
+
console.error('❌ Failed to inject context:', contextResult?.error);
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
// 4. Create new chatbox
|
|
1436
|
+
const chat = document.createElement('joe-ai-chatbox');
|
|
1437
|
+
chat.setAttribute('conversation_id', conversationId);
|
|
1438
|
+
const screenWidth = window.innerWidth;
|
|
1439
|
+
if(screenWidth <= 768){
|
|
1440
|
+
chat.setAttribute('mobile', 'true');
|
|
1441
|
+
chat.style.width = 'auto';
|
|
1442
|
+
chat.style.left = '0px';
|
|
1443
|
+
}
|
|
1444
|
+
else{
|
|
1445
|
+
chat.setAttribute('mobile', 'false');
|
|
1446
|
+
chat.style.width = options.width || '640px';
|
|
1447
|
+
chat.style.left = 'auto';
|
|
1448
|
+
}
|
|
1449
|
+
// Apply styles
|
|
1450
|
+
|
|
1451
|
+
//chat.style.height = options.height || '420px';
|
|
1452
|
+
chat.style.bottom = options.bottom || '50px';
|
|
1453
|
+
chat.style.right = options.right || '0px';
|
|
1454
|
+
chat.style.top = options.top || '50px';
|
|
1455
|
+
chat.style.position = 'fixed';
|
|
1456
|
+
chat.style.zIndex = '10000';
|
|
1457
|
+
chat.style.background = '#efefef';
|
|
1458
|
+
chat.style.border = '1px solid #fff';
|
|
1459
|
+
chat.style.borderRadius = '8px';
|
|
1460
|
+
chat.style.boxShadow = '0px 1px 4px rgba(0, 0, 0, 0.3)';
|
|
1461
|
+
chat.style.padding = '5px';
|
|
1462
|
+
chat.style.margin = '5px';
|
|
1463
|
+
|
|
1464
|
+
document.body.appendChild(chat);
|
|
1465
|
+
|
|
1466
|
+
// 5. Track it
|
|
1467
|
+
Ai._openChats[conversationId] = chat;
|
|
1468
|
+
|
|
1469
|
+
if (options.newChat) {
|
|
1470
|
+
// 6. Show soft local UI message
|
|
1471
|
+
//_joe.Ai.injectSystemMessage(conversationId, `Context injected: ${flattened.name || flattened.title || 'Object'} (${flattened._id})`);
|
|
1472
|
+
}
|
|
1473
|
+
} catch (err) {
|
|
1474
|
+
console.error('❌ spawnChat context injection failed:', err);
|
|
1475
|
+
}
|
|
1476
|
+
};
|
|
1477
|
+
|
|
1478
|
+
/* Ai.spawnChat = function(conversationId, options = {}) {
|
|
1479
|
+
if (!conversationId) {
|
|
1480
|
+
console.warn("Missing conversation ID for chat spawn.");
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// 1. Check if chatbox already open
|
|
1485
|
+
if (Ai._openChats[conversationId]) {
|
|
1486
|
+
console.log("Chatbox already open for", conversationId);
|
|
1487
|
+
Ai._openChats[conversationId].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// 2. Create new chatbox
|
|
1492
|
+
const chat = document.createElement('joe-ai-chatbox');
|
|
1493
|
+
chat.setAttribute('conversation_id', conversationId);
|
|
1494
|
+
|
|
1495
|
+
const flattened = _joe.Object.flatten();
|
|
1496
|
+
const contextInstructions = _joe.Ai.generateContextInstructions(flattened);
|
|
1497
|
+
|
|
1498
|
+
// Actually inject into AI backend (for assistant awareness) if you have that later
|
|
1499
|
+
// For now: silently show a soft system bubble
|
|
1500
|
+
_joe.Ai.injectSystemMessage(conversationId, `Context injected: ${flattened.name || flattened.title || 'Object'} (${flattened._id})`);
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
// Apply styles
|
|
1504
|
+
chat.style.width = options.width || '400px';
|
|
1505
|
+
chat.style.height = options.height || '420px';
|
|
1506
|
+
chat.style.bottom = options.bottom || '20px';
|
|
1507
|
+
chat.style.right = options.right || '20px';
|
|
1508
|
+
chat.style.position = 'fixed';
|
|
1509
|
+
chat.style.zIndex = '10000';
|
|
1510
|
+
chat.style.background = '#efefef';
|
|
1511
|
+
chat.style.border = '1px solid #fff';
|
|
1512
|
+
chat.style.borderRadius = '8px';
|
|
1513
|
+
chat.style.boxShadow = '0px 2px 10px rgba(0,0,0,0.1)';
|
|
1514
|
+
chat.style.padding = '5px';
|
|
1515
|
+
|
|
1516
|
+
document.body.appendChild(chat);
|
|
1517
|
+
|
|
1518
|
+
// 3. Track it
|
|
1519
|
+
Ai._openChats[conversationId] = chat;
|
|
1520
|
+
return chat;
|
|
1521
|
+
// 4. Optionally clean up when chatbox is removed (if you wire close buttons later)
|
|
1522
|
+
};
|
|
1523
|
+
*/
|
|
1524
|
+
Ai.generateContextInstructions = function(flattenedObj,object_id) {
|
|
1525
|
+
if (!flattenedObj) return '';
|
|
1526
|
+
|
|
1527
|
+
let context = `{{{BEGIN_OBJECT:${object_id}}}}`+
|
|
1528
|
+
"Context: You are assisting the user with the following object:\n\n";
|
|
1529
|
+
|
|
1530
|
+
context += JSON.stringify(flattenedObj, null, 2) + "\n\n";
|
|
1531
|
+
// for (const [key, value] of Object.entries(flattenedObj)) {
|
|
1532
|
+
// if (typeof value === 'object' && value !== null) {
|
|
1533
|
+
// context += `- ${key}: (linked object)\n`;
|
|
1534
|
+
// for (const [subkey, subval] of Object.entries(value)) {
|
|
1535
|
+
// context += ` • ${subkey}: ${subval}\n`;
|
|
1536
|
+
// }
|
|
1537
|
+
// } else {
|
|
1538
|
+
// context += `- ${key}: ${value}\n`;
|
|
1539
|
+
// }
|
|
1540
|
+
// }
|
|
1541
|
+
|
|
1542
|
+
context += `\nAlways refer to this context when answering questions or completing tasks related to this object.\n`+
|
|
1543
|
+
`{{{END_OBJECT:${object_id}}}}`;
|
|
1544
|
+
|
|
1545
|
+
return context;
|
|
1546
|
+
};
|
|
1547
|
+
|
|
1548
|
+
Ai.injectSystemMessage = async function(conversationId, text) {
|
|
1549
|
+
if (!conversationId || !text) return;
|
|
1550
|
+
|
|
1551
|
+
try {
|
|
1552
|
+
// Create a system-style message object
|
|
1553
|
+
const messageObj = {
|
|
1554
|
+
conversation_id: conversationId,
|
|
1555
|
+
role: 'joe',
|
|
1556
|
+
content: text,
|
|
1557
|
+
created: new Date().toISOString()
|
|
1558
|
+
};
|
|
1559
|
+
|
|
1560
|
+
// You could either push this directly into chatbox if loaded, or update server messages if you have backend ready
|
|
1561
|
+
const chatbox = document.querySelector(`joe-ai-chatbox[conversation_id="${conversationId}"]`);
|
|
1562
|
+
if (chatbox) {
|
|
1563
|
+
if (!chatbox.messages) {
|
|
1564
|
+
chatbox.messages = [];
|
|
1565
|
+
}
|
|
1566
|
+
chatbox.messages.push(messageObj);
|
|
1567
|
+
|
|
1568
|
+
// Optionally trigger a soft re-render of chatbox if needed
|
|
1569
|
+
if (typeof chatbox.renderMessages === 'function') {
|
|
1570
|
+
chatbox.renderMessages();
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
} catch (err) {
|
|
1574
|
+
console.error("❌ injectSystemMessage failed:", err);
|
|
1575
|
+
}
|
|
1576
|
+
};
|
|
1577
|
+
|
|
1578
|
+
// ---------- Autofill (Responses) ----------
|
|
1579
|
+
// Usage from schema: add `ai:{ prompt:'...' }` to a field. Core will render a
|
|
1580
|
+
// button that calls `_joe.Ai.populateField('field_name')`. You can also call
|
|
1581
|
+
// `_joe.Ai.populateField(dom, { prompt:'...' })` manually from a button.
|
|
1582
|
+
Ai.populateField = async function(target, options = {}){
|
|
1583
|
+
try{
|
|
1584
|
+
const obj = _joe.current && _joe.current.object;
|
|
1585
|
+
const schema = _joe.current && _joe.current.schema && _joe.current.schema.__schemaname;
|
|
1586
|
+
if(!obj || !schema){ return alert('No current object/schema found'); }
|
|
1587
|
+
|
|
1588
|
+
let dom = null;
|
|
1589
|
+
let explicitField = null;
|
|
1590
|
+
if (typeof target === 'string') {
|
|
1591
|
+
explicitField = target;
|
|
1592
|
+
} else {
|
|
1593
|
+
dom = target;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
let fields = [];
|
|
1597
|
+
if (options.fields && options.fields.length) {
|
|
1598
|
+
fields = options.fields;
|
|
1599
|
+
} else if (explicitField) {
|
|
1600
|
+
fields = [explicitField];
|
|
1601
|
+
} else if (dom) {
|
|
1602
|
+
const parentField = $(dom).parents('.joe-object-field');
|
|
1603
|
+
const inferredField = parentField.data('name');
|
|
1604
|
+
if (inferredField) { fields = [inferredField]; }
|
|
1605
|
+
}
|
|
1606
|
+
if(!fields.length){ return alert('No target field detected. Configure ai.fields or pass a field name.'); }
|
|
1607
|
+
|
|
1608
|
+
// If no prompt was passed explicitly, try to pick it up from the field's
|
|
1609
|
+
// schema-level ai config. Also inherit allowWeb from field.ai.allowWeb|allow_web
|
|
1610
|
+
// and any MCP-related options (mcp_enabled, mcp_toolset, etc.).
|
|
1611
|
+
let prompt = options.prompt || '';
|
|
1612
|
+
let mcpEnabled = options.mcpEnabled;
|
|
1613
|
+
let mcpToolset = options.mcpToolset;
|
|
1614
|
+
let mcpSelectedTools = options.mcpSelectedTools;
|
|
1615
|
+
let mcpInstructionsMode = options.mcpInstructionsMode;
|
|
1616
|
+
if (fields.length === 1 && _joe && typeof _joe.getField === 'function') {
|
|
1617
|
+
try{
|
|
1618
|
+
const fd = _joe.getField(fields[0]);
|
|
1619
|
+
if (fd && fd.ai) {
|
|
1620
|
+
if (!prompt && fd.ai.prompt) {
|
|
1621
|
+
prompt = fd.ai.prompt;
|
|
1622
|
+
}
|
|
1623
|
+
if (options.allowWeb === undefined && (fd.ai.allowWeb || fd.ai.allow_web)) {
|
|
1624
|
+
options.allowWeb = !!(fd.ai.allowWeb || fd.ai.allow_web);
|
|
1625
|
+
}
|
|
1626
|
+
// MCP flags for autofill: if not explicitly passed in options, inherit from field.ai.*
|
|
1627
|
+
if (typeof mcpEnabled === 'undefined' && typeof fd.ai.mcp_enabled !== 'undefined') {
|
|
1628
|
+
mcpEnabled = !!fd.ai.mcp_enabled;
|
|
1629
|
+
}
|
|
1630
|
+
if (!mcpToolset && fd.ai.mcp_toolset) {
|
|
1631
|
+
mcpToolset = fd.ai.mcp_toolset;
|
|
1632
|
+
}
|
|
1633
|
+
if (!mcpSelectedTools && fd.ai.mcp_selected_tools) {
|
|
1634
|
+
mcpSelectedTools = fd.ai.mcp_selected_tools;
|
|
1635
|
+
}
|
|
1636
|
+
if (!mcpInstructionsMode && fd.ai.mcp_instructions_mode) {
|
|
1637
|
+
mcpInstructionsMode = fd.ai.mcp_instructions_mode;
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
}catch(_e){ /* ignore */ }
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// UI feedback
|
|
1644
|
+
let originalHtml = null;
|
|
1645
|
+
if (dom && dom.innerHTML != null) {
|
|
1646
|
+
originalHtml = dom.innerHTML;
|
|
1647
|
+
dom.disabled = true;
|
|
1648
|
+
dom.innerHTML = (options.loadingLabel || 'Thinking...');
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
const payload = {
|
|
1652
|
+
object_id: obj._id,
|
|
1653
|
+
schema: schema,
|
|
1654
|
+
fields: fields,
|
|
1655
|
+
prompt: prompt || '',
|
|
1656
|
+
assistant_id: options.assistant_id || undefined,
|
|
1657
|
+
allow_web: !!options.allowWeb,
|
|
1658
|
+
// Optional MCP configuration for autofill.
|
|
1659
|
+
mcp_enabled: mcpEnabled === true,
|
|
1660
|
+
mcp_toolset: mcpToolset || undefined,
|
|
1661
|
+
mcp_selected_tools: mcpSelectedTools || undefined,
|
|
1662
|
+
mcp_instructions_mode: mcpInstructionsMode || undefined,
|
|
1663
|
+
save_history: !!options.saveHistory,
|
|
1664
|
+
save_itemtype: options.saveItemtype || undefined,
|
|
1665
|
+
model: options.model || undefined
|
|
1666
|
+
};
|
|
1667
|
+
|
|
1668
|
+
const resp = await fetch('/API/plugin/chatgpt/autofill', {
|
|
1669
|
+
method: 'POST',
|
|
1670
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1671
|
+
body: JSON.stringify(payload)
|
|
1672
|
+
}).then(r => r.json());
|
|
1673
|
+
|
|
1674
|
+
if(!resp || resp.success !== true){
|
|
1675
|
+
console.error('AI autofill error:', resp && resp.error);
|
|
1676
|
+
alert('AI autofill failed' + (resp && resp.error ? (': ' + resp.error) : ''));
|
|
1677
|
+
}else{
|
|
1678
|
+
const patch = resp.patch || {};
|
|
1679
|
+
Ai.applyAutofillPatch(patch);
|
|
1680
|
+
if(options.autoSave === true){
|
|
1681
|
+
_joe.updateObject(null, null, true, null, options.skipValidation === true);
|
|
1682
|
+
}
|
|
1683
|
+
if(options.openChatAfter === true){
|
|
1684
|
+
_joe.Ai.spawnChatHelper(obj._id);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
if (dom && originalHtml != null) {
|
|
1689
|
+
dom.disabled = false;
|
|
1690
|
+
dom.innerHTML = originalHtml;
|
|
1691
|
+
}
|
|
1692
|
+
}catch(e){
|
|
1693
|
+
console.error('populateField error', e);
|
|
1694
|
+
alert('Error running AI autofill');
|
|
1695
|
+
try{
|
|
1696
|
+
if (dom) { dom.disabled = false; }
|
|
1697
|
+
}catch(_e){}
|
|
1698
|
+
}
|
|
1699
|
+
};
|
|
1700
|
+
|
|
1701
|
+
/**
|
|
1702
|
+
* Run the Thought agent for the current schema object.
|
|
1703
|
+
* Used by the core `proposeThought` field.
|
|
1704
|
+
*/
|
|
1705
|
+
Ai.runProposeThought = function(btnOrScopeId, textareaIdOrModel, maybeModel){
|
|
1706
|
+
try{
|
|
1707
|
+
// Back-compat: allow old signature (scopeId, textareaId, model)
|
|
1708
|
+
var btn = (btnOrScopeId && btnOrScopeId.tagName) ? btnOrScopeId : null;
|
|
1709
|
+
var scopeId = btn ? textareaIdOrModel : btnOrScopeId;
|
|
1710
|
+
var textareaId = btn ? maybeModel : textareaIdOrModel;
|
|
1711
|
+
var model = btn ? undefined : maybeModel;
|
|
1712
|
+
|
|
1713
|
+
if (btn) {
|
|
1714
|
+
if (btn.dataset.running === '1') { return; }
|
|
1715
|
+
btn.dataset.running = '1';
|
|
1716
|
+
btn.classList.add('joe-loading');
|
|
1717
|
+
btn.setAttribute('disabled','disabled');
|
|
1718
|
+
}
|
|
1719
|
+
if (!scopeId) {
|
|
1720
|
+
alert('Save the item before proposing thoughts.');
|
|
1721
|
+
if (btn) { btn.dataset.running=''; btn.classList.remove('joe-loading'); btn.removeAttribute('disabled'); }
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
var ta = document.getElementById(textareaId);
|
|
1725
|
+
if (!ta) {
|
|
1726
|
+
alert('Prompt field not found.');
|
|
1727
|
+
if (btn) { btn.dataset.running=''; btn.classList.remove('joe-loading'); btn.removeAttribute('disabled'); }
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
var prompt = (ta.value || '').trim();
|
|
1731
|
+
if (!prompt) {
|
|
1732
|
+
alert('Please enter a prompt for the Thought run.');
|
|
1733
|
+
if (btn) { btn.dataset.running=''; btn.classList.remove('joe-loading'); btn.removeAttribute('disabled'); }
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
// Collect selected OpenAI file ids (trim to 10)
|
|
1737
|
+
var selId = 'propose_thought_files_' + scopeId;
|
|
1738
|
+
var fileIds = [];
|
|
1739
|
+
try{
|
|
1740
|
+
var sel = document.getElementById(selId);
|
|
1741
|
+
if(sel){
|
|
1742
|
+
var selected = Array.prototype.slice.call(sel.selectedOptions || []);
|
|
1743
|
+
fileIds = selected.map(function(o){ return o.value; }).filter(Boolean);
|
|
1744
|
+
if(fileIds.length > 10){ fileIds = fileIds.slice(0,10); }
|
|
1745
|
+
}
|
|
1746
|
+
}catch(_e){/*noop*/}
|
|
1747
|
+
|
|
1748
|
+
var params = {
|
|
1749
|
+
agent_id: 'thought_agent_default',
|
|
1750
|
+
scope_id: scopeId,
|
|
1751
|
+
user_input: prompt,
|
|
1752
|
+
openai_file_ids: fileIds
|
|
1753
|
+
};
|
|
1754
|
+
if (model) {
|
|
1755
|
+
params.model = model;
|
|
1756
|
+
}
|
|
1757
|
+
var body = {
|
|
1758
|
+
jsonrpc: '2.0',
|
|
1759
|
+
id: String(Date.now()),
|
|
1760
|
+
method: 'runThoughtAgent',
|
|
1761
|
+
params: params
|
|
1762
|
+
};
|
|
1763
|
+
fetch('/mcp', {
|
|
1764
|
+
method: 'POST',
|
|
1765
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1766
|
+
body: JSON.stringify(body)
|
|
1767
|
+
}).then(function(res){
|
|
1768
|
+
return res.json();
|
|
1769
|
+
}).then(function(payload){
|
|
1770
|
+
var result = (payload && (payload.result || payload)) || {};
|
|
1771
|
+
var count = result.proposed_thoughts_count || 0;
|
|
1772
|
+
if (window._joe && _joe.toast) {
|
|
1773
|
+
_joe.toast('Thought agent run complete. Proposed ' + count + ' thoughts.');
|
|
1774
|
+
} else {
|
|
1775
|
+
alert('Thought agent run complete. Proposed ' + count + ' thoughts.');
|
|
1776
|
+
}
|
|
1777
|
+
if (btn) { btn.dataset.running=''; btn.classList.remove('joe-loading'); btn.removeAttribute('disabled'); }
|
|
1778
|
+
}).catch(function(err){
|
|
1779
|
+
console.error('Ai.runProposeThought error', err);
|
|
1780
|
+
alert('Failed to run Thought agent: ' + (err && err.message || err));
|
|
1781
|
+
if (btn) { btn.dataset.running=''; btn.classList.remove('joe-loading'); btn.removeAttribute('disabled'); }
|
|
1782
|
+
});
|
|
1783
|
+
}catch(e){
|
|
1784
|
+
console.error('Ai.runProposeThought error', e);
|
|
1785
|
+
try{ if (btn) { btn.dataset.running=''; btn.classList.remove('joe-loading'); btn.removeAttribute('disabled'); } }catch(_e){}
|
|
1786
|
+
}
|
|
1787
|
+
};
|
|
1788
|
+
|
|
1789
|
+
Ai.applyAutofillPatch = function(patch = {}){
|
|
1790
|
+
Object.keys(patch).forEach(function(fname){
|
|
1791
|
+
try {
|
|
1792
|
+
const $container = $(`.joe-object-field[data-name="${fname}"]`);
|
|
1793
|
+
if(!$container.length){ return; }
|
|
1794
|
+
const $el = $container.find('.joe-field').eq(0);
|
|
1795
|
+
if(!$el.length){ return; }
|
|
1796
|
+
|
|
1797
|
+
// Determine current value (text fields, selects, rich editors, etc.)
|
|
1798
|
+
let currentVal = '';
|
|
1799
|
+
if ($el.is('select') || $el.is('input,textarea')) {
|
|
1800
|
+
currentVal = $el.val();
|
|
1801
|
+
} else {
|
|
1802
|
+
currentVal = $el.text() || '';
|
|
1803
|
+
}
|
|
1804
|
+
const isBlank = !currentVal || String(currentVal).trim() === '';
|
|
1805
|
+
|
|
1806
|
+
if (!isBlank) {
|
|
1807
|
+
const label = fname;
|
|
1808
|
+
const ok = window.confirm(`Replace existing value for "${label}" with AI suggestion?`);
|
|
1809
|
+
if (!ok) { return; }
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
Ai._setFieldValue(fname, patch[fname]);
|
|
1813
|
+
} catch (e) {
|
|
1814
|
+
console.warn('applyAutofillPatch error for field', fname, e);
|
|
1815
|
+
}
|
|
1816
|
+
});
|
|
1817
|
+
};
|
|
1818
|
+
|
|
1819
|
+
Ai._setFieldValue = function(fieldName, value){
|
|
1820
|
+
const $container = $(`.joe-object-field[data-name="${fieldName}"]`);
|
|
1821
|
+
if(!$container.length){ return false; }
|
|
1822
|
+
const $el = $container.find('.joe-field').eq(0);
|
|
1823
|
+
if(!$el.length){ return false; }
|
|
1824
|
+
|
|
1825
|
+
const ftype = $el.data('ftype');
|
|
1826
|
+
try{
|
|
1827
|
+
switch(ftype){
|
|
1828
|
+
case 'tinymce': {
|
|
1829
|
+
const editorId = $el.data('texteditor_id');
|
|
1830
|
+
const ed = window.tinymce && editorId ? window.tinymce.get(editorId) : null;
|
|
1831
|
+
if(ed){ ed.setContent(value || ''); }
|
|
1832
|
+
else { $el.val(value || ''); }
|
|
1833
|
+
break;
|
|
1834
|
+
}
|
|
1835
|
+
case 'ckeditor': {
|
|
1836
|
+
const editorId = $el.data('ckeditor_id');
|
|
1837
|
+
const ed = (window.CKEDITOR && CKEDITOR.instances) ? CKEDITOR.instances[editorId] : null;
|
|
1838
|
+
if(ed){ ed.setData(value || ''); }
|
|
1839
|
+
else { $el.val(value || ''); }
|
|
1840
|
+
break;
|
|
1841
|
+
}
|
|
1842
|
+
case 'ace': {
|
|
1843
|
+
const aceId = $el.data('ace_id');
|
|
1844
|
+
const ed = (_joe && _joe.ace_editors) ? _joe.ace_editors[aceId] : null;
|
|
1845
|
+
if(ed){ ed.setValue(value || '', -1); }
|
|
1846
|
+
else { $el.val(value || ''); }
|
|
1847
|
+
break;
|
|
1848
|
+
}
|
|
1849
|
+
default: {
|
|
1850
|
+
if($el.is('select')){
|
|
1851
|
+
$el.val(value);
|
|
1852
|
+
}else if($el.is('input,textarea')){
|
|
1853
|
+
$el.val(value);
|
|
1854
|
+
}else{
|
|
1855
|
+
// Fallback: try innerText/innerHTML for custom components
|
|
1856
|
+
$el.text(typeof value === 'string' ? value : JSON.stringify(value));
|
|
1857
|
+
}
|
|
1858
|
+
$el.trigger('input');
|
|
1859
|
+
$el.trigger('change');
|
|
1860
|
+
break;
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
$container.addClass('changed');
|
|
1864
|
+
return true;
|
|
1865
|
+
}catch(e){
|
|
1866
|
+
console.warn('Failed to set field', fieldName, e);
|
|
1867
|
+
return false;
|
|
1868
|
+
}
|
|
1869
|
+
};
|
|
1870
|
+
|
|
1871
|
+
// --------------------------------------------------
|
|
1872
|
+
// File helpers for AI integrations
|
|
1873
|
+
// --------------------------------------------------
|
|
1874
|
+
Ai.getFilesForObject = function(options = {}){
|
|
1875
|
+
try{
|
|
1876
|
+
var onlyWithOpenAI = options.onlyWithOpenAI === true;
|
|
1877
|
+
var includeNested = options.includeNested === true; // not used in v1
|
|
1878
|
+
var obj = (_joe && _joe.current && _joe.current.object) || {};
|
|
1879
|
+
var schema = (_joe && _joe.current && _joe.current.schema) || {};
|
|
1880
|
+
// Prefer the live, constructed field defs; fall back to schema.fields
|
|
1881
|
+
var liveFieldDefs = (_joe && _joe.current && Array.isArray(_joe.current.fields) && _joe.current.fields) || [];
|
|
1882
|
+
var schemaFields = (schema && schema.fields) || [];
|
|
1883
|
+
var fieldNames = [];
|
|
1884
|
+
// collect from live constructed defs
|
|
1885
|
+
liveFieldDefs.forEach(function(fd){
|
|
1886
|
+
if(fd && fd.name && fd.type === 'uploader'){ fieldNames.push(fd.name); }
|
|
1887
|
+
});
|
|
1888
|
+
// also collect from raw schema (string or object), dedupe
|
|
1889
|
+
schemaFields.forEach(function(f){
|
|
1890
|
+
var fname = (typeof f === 'string') ? f : (f && (f.name || f.field || f.prop));
|
|
1891
|
+
if(!fname){ return; }
|
|
1892
|
+
try{
|
|
1893
|
+
var fd = _joe.getField(fname);
|
|
1894
|
+
if(fd && fd.type === 'uploader' && fieldNames.indexOf(fname) === -1){
|
|
1895
|
+
fieldNames.push(fname);
|
|
1896
|
+
}
|
|
1897
|
+
}catch(_e){ /* ignore */ }
|
|
1898
|
+
});
|
|
1899
|
+
|
|
1900
|
+
var out = [];
|
|
1901
|
+
fieldNames.forEach(function(fname){
|
|
1902
|
+
var val = obj[fname];
|
|
1903
|
+
if(!val){ return; }
|
|
1904
|
+
var list = Array.isArray(val) ? val : [val];
|
|
1905
|
+
list.forEach(function(file){
|
|
1906
|
+
if(!file || typeof file !== 'object'){ return; }
|
|
1907
|
+
var item = {
|
|
1908
|
+
fieldName: fname,
|
|
1909
|
+
filename: file.filename || (file.url && String(file.url).split('/').pop()) || '',
|
|
1910
|
+
url: file.url || null,
|
|
1911
|
+
openai_file_id: file.openai_file_id || null,
|
|
1912
|
+
openai_status: file.openai_status || null,
|
|
1913
|
+
size: file.size || null,
|
|
1914
|
+
type: file.type || null,
|
|
1915
|
+
path: fname
|
|
1916
|
+
};
|
|
1917
|
+
if(onlyWithOpenAI && !item.openai_file_id){ return; }
|
|
1918
|
+
out.push(item);
|
|
1919
|
+
});
|
|
1920
|
+
});
|
|
1921
|
+
return out;
|
|
1922
|
+
}catch(e){
|
|
1923
|
+
console.warn('Ai.getFilesForObject error', e);
|
|
1924
|
+
return [];
|
|
1925
|
+
}
|
|
1926
|
+
};
|
|
1927
|
+
|
|
1928
|
+
Ai.renderFilesSelector = function(selectId, opts = {}){
|
|
1929
|
+
try{
|
|
1930
|
+
var cap = typeof opts.cap === 'number' ? opts.cap : 10;
|
|
1931
|
+
var disableWithoutOpenAI = opts.disableWithoutOpenAI !== false; // default true
|
|
1932
|
+
var onlyWithOpenAI = false; // show all; disable those missing ids
|
|
1933
|
+
var files = Ai.getFilesForObject({ onlyWithOpenAI, includeNested:false });
|
|
1934
|
+
// Fallback: scan top-level arrays for file-shaped objects if nothing found
|
|
1935
|
+
if(!files || !files.length){
|
|
1936
|
+
try{
|
|
1937
|
+
var obj = (_joe && _joe.current && _joe.current.object) || {};
|
|
1938
|
+
Object.keys(obj || {}).forEach(function(k){
|
|
1939
|
+
var v = obj[k];
|
|
1940
|
+
if(Array.isArray(v)){
|
|
1941
|
+
v.forEach(function(it){
|
|
1942
|
+
if(it && typeof it === 'object' && (it.filename || it.url)){
|
|
1943
|
+
files.push({
|
|
1944
|
+
fieldName: k,
|
|
1945
|
+
filename: it.filename || (it.url && String(it.url).split('/').pop()) || '',
|
|
1946
|
+
url: it.url || null,
|
|
1947
|
+
openai_file_id: it.openai_file_id || null,
|
|
1948
|
+
openai_status: it.openai_status || null,
|
|
1949
|
+
size: it.size || null,
|
|
1950
|
+
type: it.type || null,
|
|
1951
|
+
path: k
|
|
1952
|
+
});
|
|
1953
|
+
}
|
|
1954
|
+
});
|
|
1955
|
+
}
|
|
1956
|
+
});
|
|
1957
|
+
}catch(_e){ /* noop */ }
|
|
1958
|
+
}
|
|
1959
|
+
var sel = document.getElementById(selectId);
|
|
1960
|
+
if(!sel){ return; }
|
|
1961
|
+
sel.innerHTML = '';
|
|
1962
|
+
files.forEach(function(f){
|
|
1963
|
+
var opt = document.createElement('option');
|
|
1964
|
+
opt.value = f.openai_file_id || ''; // empty when missing
|
|
1965
|
+
opt.textContent = f.filename || f.openai_file_id || '(unnamed file)';
|
|
1966
|
+
if(disableWithoutOpenAI && !f.openai_file_id){
|
|
1967
|
+
opt.disabled = true;
|
|
1968
|
+
opt.textContent += ' (not uploaded to OpenAI)';
|
|
1969
|
+
}
|
|
1970
|
+
sel.appendChild(opt);
|
|
1971
|
+
});
|
|
1972
|
+
// Enforce cap on submit: caller will trim again before sending
|
|
1973
|
+
// If still empty, retry shortly in case the panel hasn't fully constructed fields yet
|
|
1974
|
+
if(!files.length){
|
|
1975
|
+
setTimeout(function(){ try{ Ai.renderFilesSelector(selectId, opts); }catch(_e){} }, 200);
|
|
1976
|
+
}
|
|
1977
|
+
}catch(e){
|
|
1978
|
+
console.warn('Ai.renderFilesSelector error', e);
|
|
1979
|
+
}
|
|
1980
|
+
};
|
|
1981
|
+
|
|
1982
|
+
Ai.runPromptSelection = function(btnOrObjectId, selectId, filesSelectId){
|
|
1983
|
+
try{
|
|
1984
|
+
// Back-compat: allow old signature (objectId, selectId, filesSelectId)
|
|
1985
|
+
var btn = (btnOrObjectId && btnOrObjectId.tagName) ? btnOrObjectId : null;
|
|
1986
|
+
var objectId = btn ? selectId : btnOrObjectId;
|
|
1987
|
+
var realSelectId = btn ? filesSelectId : selectId;
|
|
1988
|
+
var realFilesSelectId = btn ? arguments[3] : filesSelectId; // handle both signatures
|
|
1989
|
+
|
|
1990
|
+
if (btn) {
|
|
1991
|
+
if (btn.dataset.running === '1') { return; }
|
|
1992
|
+
btn.dataset.running = '1';
|
|
1993
|
+
btn.classList.add('joe-loading');
|
|
1994
|
+
btn.setAttribute('disabled','disabled');
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
var sel = document.getElementById(realSelectId);
|
|
1998
|
+
if(!sel || !sel.value){
|
|
1999
|
+
alert('Select a prompt first.');
|
|
2000
|
+
if (btn) { btn.dataset.running=''; btn.classList.remove('joe-loading'); btn.removeAttribute('disabled'); }
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
var promptId = sel.value;
|
|
2004
|
+
// Collect selected OpenAI file ids (trim to 10)
|
|
2005
|
+
var fileIds = [];
|
|
2006
|
+
try{
|
|
2007
|
+
var fsel = document.getElementById(realFilesSelectId);
|
|
2008
|
+
if(fsel){
|
|
2009
|
+
var selected = Array.prototype.slice.call(fsel.selectedOptions || []);
|
|
2010
|
+
fileIds = selected.map(function(o){ return o.value; }).filter(Boolean);
|
|
2011
|
+
if(fileIds.length > 10){ fileIds = fileIds.slice(0,10); }
|
|
2012
|
+
}
|
|
2013
|
+
}catch(_e){}
|
|
2014
|
+
|
|
2015
|
+
// Build references for this object when the prompt expects the current itemtype
|
|
2016
|
+
var obj = (_joe && _joe.current && _joe.current.object) || {};
|
|
2017
|
+
var prompt = $J.get(promptId,'ai_prompt');
|
|
2018
|
+
var params = {};
|
|
2019
|
+
try{
|
|
2020
|
+
(prompt && prompt.content_items || []).forEach(function(ci){
|
|
2021
|
+
if(ci && ci.itemtype === obj.itemtype && ci.reference){
|
|
2022
|
+
params[ci.reference] = obj._id;
|
|
2023
|
+
}
|
|
2024
|
+
});
|
|
2025
|
+
}catch(_e){}
|
|
2026
|
+
|
|
2027
|
+
$.ajax('/API/plugin/chatgpt/executeJOEAiPrompt',{
|
|
2028
|
+
method:'POST',
|
|
2029
|
+
data:{
|
|
2030
|
+
ai_prompt: promptId,
|
|
2031
|
+
params: params,
|
|
2032
|
+
openai_file_ids: fileIds
|
|
2033
|
+
},
|
|
2034
|
+
success:function(resp){
|
|
2035
|
+
try{
|
|
2036
|
+
// Try to apply a patch if the response contains one
|
|
2037
|
+
var text = (resp && resp.response) || '';
|
|
2038
|
+
if(text){
|
|
2039
|
+
try{
|
|
2040
|
+
var parsed = JSON.parse(text);
|
|
2041
|
+
if(parsed && parsed.patch){
|
|
2042
|
+
_joe.Ai.applyAutofillPatch(parsed.patch);
|
|
2043
|
+
}
|
|
2044
|
+
}catch(_e){ /* ignore parse errors */ }
|
|
2045
|
+
}
|
|
2046
|
+
}catch(_e){}
|
|
2047
|
+
// Refresh AI responses field
|
|
2048
|
+
try{ _joe.Fields.rerender && _joe.Fields.rerender('ai_responses'); }catch(_e){}
|
|
2049
|
+
if(_joe.toast){
|
|
2050
|
+
_joe.toast('AI prompt run complete.');
|
|
2051
|
+
}
|
|
2052
|
+
if (btn) { btn.dataset.running=''; btn.classList.remove('joe-loading'); btn.removeAttribute('disabled'); }
|
|
2053
|
+
},
|
|
2054
|
+
error:function(xhr,status,err){
|
|
2055
|
+
var message = (xhr && (xhr.responseJSON && (xhr.responseJSON.error || xhr.responseJSON.code) || xhr.responseText)) || err || status || 'Prompt run failed';
|
|
2056
|
+
alert(message);
|
|
2057
|
+
if (btn) { btn.dataset.running=''; btn.classList.remove('joe-loading'); btn.removeAttribute('disabled'); }
|
|
2058
|
+
}
|
|
2059
|
+
});
|
|
2060
|
+
}catch(e){
|
|
2061
|
+
console.warn('runPromptSelection error', e);
|
|
2062
|
+
alert('Failed to run prompt.');
|
|
2063
|
+
try{ if (btn) { btn.dataset.running=''; btn.classList.remove('joe-loading'); btn.removeAttribute('disabled'); } }catch(_e){}
|
|
2064
|
+
}
|
|
2065
|
+
};
|
|
2066
|
+
|
|
2067
|
+
|
|
2068
|
+
// Attach AI to _joe
|
|
2069
|
+
if (window._joe) {
|
|
2070
|
+
_joe.Ai = Ai;
|
|
2071
|
+
} else {
|
|
2072
|
+
console.warn('joeAI.js loaded before _joe was ready.');
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
})();
|
|
2053
2076
|
|