json-object-editor 0.10.668 → 0.10.670

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "json-object-editor",
3
- "version": "0.10.668",
3
+ "version": "0.10.670",
4
4
  "description": "JOE the Json Object Editor | Platform Edition",
5
5
  "main": "app.js",
6
6
  "scripts": {
@@ -19,6 +19,7 @@
19
19
  <link rel="stylesheet" href="${JOEPATH}js/plugins/c3/c3.min.css"/>
20
20
  <link rel="stylesheet" href="${JOEPATH}capp/capp.css"/>
21
21
  <link href="/JsonObjectEditor/css/joe.css" rel="stylesheet" type="text/css"/>
22
+ <link href="/JsonObjectEditor/css/joe-ai.css" rel="stylesheet" type="text/css"/>
22
23
  <!--manifest.json-->
23
24
  <link rel="manifest" href="/manifest.json">
24
25
 
@@ -341,6 +342,7 @@
341
342
  </script>
342
343
 
343
344
  <script src="${JOEPATH}js/joe-ai.js"></script>
345
+ <script src="${JOEPATH}web-components/field-jobs-container.js"></script>
344
346
  <!-- <script src="https://apis.google.com/js/client.js?onload=checkGoogleAuth"></script> -->
345
347
  </body>
346
348
  </html>
@@ -618,6 +618,7 @@ var fields = {
618
618
  type:'content',
619
619
  reloadable:true,
620
620
  icon:'ai_prompt',
621
+ queryJobs:true,
621
622
  run:function(obj){
622
623
  if(!obj || !obj._id){
623
624
  return '<joe-text>Save this item before running AI prompts.</joe-text>';
@@ -648,7 +649,13 @@ var fields = {
648
649
  html += '<div class="joe-field-comment" style="margin-top:8px;">Attach files (optional)</div>';
649
650
  html += '<select id="'+filesSelId+'" multiple class="joe-prompt-select"></select>';
650
651
  html += '<script>(function(){ try{ _joe && _joe.Ai && _joe.Ai.renderFilesSelector && _joe.Ai.renderFilesSelector("'+filesSelId+'",{ cap:10, disableWithoutOpenAI:true }); }catch(e){} })();</script>';
651
- html += '<joe-button class="joe-button joe-ai-button joe-iconed-button" onclick="_joe.Ai.runPromptSelection(this,\''+obj._id+'\',\''+selId+'\',\''+filesSelId+'\')">Run AI Prompt</joe-button>';
652
+ // Generate progress token using centralized function
653
+ var progressToken = (_joe && _joe.Ai && _joe.Ai.generateProgressToken)
654
+ ? _joe.Ai.generateProgressToken(obj._id, 'select_prompt')
655
+ : '';
656
+ html += '<joe-button class="joe-button joe-ai-button joe-iconed-button" data-progress-token="' + progressToken + '" data-field-name="select_prompt" data-object-id="' + obj._id + '" data-original-text="Run AI Prompt" onclick="_joe.Ai.runPromptSelection(this,\''+obj._id+'\',\''+selId+'\',\''+filesSelId+'\')">Run AI Prompt</joe-button>';
657
+ html += '<div class="joe-ai-progress" data-for-token="' + progressToken + '" style="font-size:12px; color:#666; margin-top:4px; min-height:16px;"></div>';
658
+ html += '<div class="joe-ai-jobs" data-for-token="' + progressToken + '" style="margin-top:4px;"></div>';
652
659
  return html;
653
660
  }
654
661
  },
@@ -657,6 +664,7 @@ var fields = {
657
664
  type:'content',
658
665
  reloadable:true,
659
666
  icon:'ai_thought',
667
+ queryJobs:true,
660
668
  run:function(obj){
661
669
  if (!obj || !obj._id) {
662
670
  return '<joe-text>Save this item before proposing Thoughts.</joe-text>';
@@ -692,8 +700,14 @@ var fields = {
692
700
  var m = String(fieldDef.model).replace(/'/g, "\\'");
693
701
  args += ",'" + m + "'";
694
702
  }
695
- html += '<joe-button class="joe-button joe-ai-button joe-iconed-button" ';
703
+ // Generate progress token using centralized function
704
+ var progressToken = (_joe && _joe.Ai && _joe.Ai.generateProgressToken)
705
+ ? _joe.Ai.generateProgressToken(obj._id, 'proposeThought')
706
+ : '';
707
+ html += '<joe-button class="joe-button joe-ai-button joe-iconed-button" data-progress-token="' + progressToken + '" data-field-name="proposeThought" data-object-id="' + obj._id + '" data-original-text="Run Thought Agent" ';
696
708
  html += 'onclick="_joe.Ai.runProposeThought(this,'+ args +')">Run Thought Agent</joe-button>';
709
+ html += '<div class="joe-ai-progress" data-for-token="' + progressToken + '" style="font-size:12px; color:#666; margin-top:4px; min-height:16px;"></div>';
710
+ html += '<div class="joe-ai-jobs" data-for-token="' + progressToken + '" style="margin-top:4px;"></div>';
697
711
  return html;
698
712
  }
699
713
  },
package/server/init.js CHANGED
@@ -62,7 +62,7 @@ var app = function(config){
62
62
 
63
63
  });
64
64
  }
65
- ['Apps','Schemas','Mongo','MySQL','Comments','Utils'].map(function(m){
65
+ ['Apps','Schemas','Mongo','MySQL','Comments','Utils','AiJobs'].map(function(m){
66
66
  initializeModule(m);
67
67
  });
68
68
  //initializeModule('Apps');
@@ -104,6 +104,14 @@ var app = function(config){
104
104
  } catch (e) {
105
105
  console.log('[MCP] attach error:', e && e.message);
106
106
  }
107
+
108
+ // Attach AiJobs routes
109
+ try {
110
+ const AiJobs = require('./modules/AiJobs.js');
111
+ if (AiJobs && AiJobs.init) AiJobs.init();
112
+ } catch (e) {
113
+ console.log('[AiJobs] attach error:', e && e.message);
114
+ }
107
115
 
108
116
  JOE.Sites = require('./modules/Sites.js');
109
117
  JOE.io = require('./modules/Socket.js');
@@ -0,0 +1,412 @@
1
+ // modules/AiJobs.js
2
+
3
+ /**
4
+ * AI Jobs tracking module for JOE.
5
+ * Tracks active AI operations (prompts, autofill, thoughts) by object and identifier.
6
+ * Jobs are keyed by lookup key: {objectId}_{identifier}
7
+ */
8
+
9
+ const AiJobs = {
10
+ // Active jobs storage: lookupKey -> array of job objects
11
+ active: {},
12
+
13
+ /**
14
+ * Emit socket event for job update
15
+ * TODO: Phase 2 - Remove socket, use polling only
16
+ * @param {string} lookupKey - Lookup key ({objectId}_{fieldName})
17
+ * @param {object} job - Job object
18
+ */
19
+ emitJobUpdate: function(lookupKey, job) {
20
+ // Socket removed for Phase 1 - will use polling only
21
+ // TODO: Remove this function in Phase 2 or re-enable if socket needed
22
+ },
23
+
24
+ /**
25
+ * Extract lookup key from token: {objectId}|{fieldName}|{timestamp}|{userid}
26
+ * Returns: {objectId}_{fieldName}
27
+ *
28
+ * Examples:
29
+ * "objId|fieldName|123|userid" -> "objId_fieldName"
30
+ * "objId|description|456|userid" -> "objId_description"
31
+ * "objId|select_prompt|789|userid" -> "objId_select_prompt"
32
+ */
33
+ extractKey: function(token) {
34
+ if (!token || typeof token !== 'string') return null;
35
+ // Token format: objectId|fieldName|timestamp|userid (4 parts separated by |)
36
+ var parts = token.split('|');
37
+ if (parts.length !== 4) {
38
+ return null;
39
+ }
40
+ // New format: parts[0] = objectId, parts[1] = fieldName
41
+ // Return lookup key using underscore separator (consistent with API routes)
42
+ return parts[0] + '_' + parts[1];
43
+ },
44
+
45
+ /**
46
+ * Create/register a job
47
+ * @param {string} token - Full job token
48
+ * @param {object} jobData - { promptId?, promptName?, fieldId?, startTime?, status?, progress?, total?, message? }
49
+ * @returns {boolean} Success
50
+ */
51
+ createJob: function(token, jobData) {
52
+ if (!token) return false;
53
+ var lookupKey = this.extractKey(token);
54
+ if (!lookupKey) {
55
+ console.warn('[AiJobs] createJob: Invalid token format:', token);
56
+ return false;
57
+ }
58
+
59
+ if (!this.active[lookupKey]) {
60
+ this.active[lookupKey] = [];
61
+ }
62
+
63
+ var job = {
64
+ token: token,
65
+ startTime: jobData.startTime || new Date().toISOString(),
66
+ status: jobData.status || 'running',
67
+ promptId: jobData.promptId || null,
68
+ promptName: jobData.promptName || null,
69
+ fieldId: jobData.fieldId || null,
70
+ progress: jobData.progress != null ? jobData.progress : 0,
71
+ total: jobData.total != null ? jobData.total : null,
72
+ message: jobData.message || ''
73
+ };
74
+
75
+ // Check if job already exists (avoid duplicates)
76
+ var existing = this.active[lookupKey].find(function(j) {
77
+ return j.token === token;
78
+ });
79
+ if (existing) {
80
+ // Update existing job
81
+ Object.assign(existing, job);
82
+ // Emit socket update
83
+ this.emitJobUpdate(lookupKey, existing);
84
+ } else {
85
+ this.active[lookupKey].push(job);
86
+ // Emit socket update for new job
87
+ this.emitJobUpdate(lookupKey, job);
88
+ }
89
+
90
+ return true;
91
+ },
92
+
93
+ /**
94
+ * Remove a job by token
95
+ * @param {string} token - Full job token
96
+ * @returns {boolean} Success
97
+ */
98
+ removeJob: function(token) {
99
+ if (!token) return false;
100
+ var lookupKey = this.extractKey(token);
101
+ if (!lookupKey || !this.active[lookupKey]) return false;
102
+
103
+ this.active[lookupKey] = this.active[lookupKey].filter(function(j) {
104
+ return j.token !== token;
105
+ });
106
+
107
+ // Clean up empty arrays
108
+ if (this.active[lookupKey].length === 0) {
109
+ delete this.active[lookupKey];
110
+ }
111
+
112
+ return true;
113
+ },
114
+
115
+ /**
116
+ * Remove a job with delay (updates status first, then removes after delay)
117
+ * @param {string} token - Full job token
118
+ * @param {string} finalStatus - Optional: 'error' or 'complete' (default: 'complete')
119
+ * @param {string} message - Optional: Final message
120
+ * @param {number} delaySeconds - Optional: Delay before removal in seconds (default: 10)
121
+ * @returns {number|null} Timeout ID (can be used to cancel), or null if job not found
122
+ */
123
+ removeJobWithDelay: function(token, finalStatus, message, delaySeconds) {
124
+ if (!token) return null;
125
+ finalStatus = finalStatus || 'complete';
126
+ delaySeconds = delaySeconds != null ? delaySeconds : 10;
127
+
128
+ // Update job status first
129
+ var lookupKey = this.extractKey(token);
130
+ if (!lookupKey) return null;
131
+
132
+ var updated = this.updateJob(token, {
133
+ status: finalStatus,
134
+ message: message || (finalStatus === 'error' ? 'Error occurred' : 'Complete'),
135
+ progress: finalStatus === 'error' ? null : 100,
136
+ total: finalStatus === 'error' ? null : 100
137
+ });
138
+
139
+ if (!updated) {
140
+ return null; // Job not found
141
+ }
142
+
143
+ // Emit final status update
144
+ var job = this.active[lookupKey] && this.active[lookupKey].find(function(j) {
145
+ return j.token === token;
146
+ });
147
+ if (job) {
148
+ this.emitJobUpdate(lookupKey, job);
149
+ }
150
+
151
+ // Schedule removal after delay
152
+ var delayMs = delaySeconds * 1000;
153
+ var self = this;
154
+ var timeoutId = setTimeout(function() {
155
+ self.removeJob(token);
156
+ }, delayMs);
157
+
158
+ return timeoutId;
159
+ },
160
+
161
+ /**
162
+ * Get all active jobs for an object
163
+ * @param {string} objectId - Object ID
164
+ * @returns {array} Array of { lookupKey, jobs: [...] }
165
+ */
166
+ getActiveJobsForObject: function(objectId) {
167
+ if (!objectId) return [];
168
+
169
+ var results = [];
170
+ for (var lookupKey in this.active) {
171
+ // Check if lookupKey starts with objectId_
172
+ if (lookupKey.indexOf(objectId + '_') === 0) {
173
+ var jobs = this.active[lookupKey].filter(function(j) {
174
+ return j.status === 'running' || j.status === 'starting';
175
+ });
176
+ if (jobs.length > 0) {
177
+ results.push({
178
+ lookupKey: lookupKey,
179
+ jobs: jobs
180
+ });
181
+ }
182
+ }
183
+ }
184
+ return results;
185
+ },
186
+
187
+ /**
188
+ * Update job progress
189
+ * @param {string} token - Full job token
190
+ * @param {object} updates - { progress?, total?, message?, status? }
191
+ * @returns {boolean} Success
192
+ */
193
+ updateJob: function(token, updates) {
194
+ if (!token) return false;
195
+ var lookupKey = this.extractKey(token);
196
+ if (!lookupKey || !this.active[lookupKey]) return false;
197
+
198
+ var job = this.active[lookupKey].find(function(j) {
199
+ return j.token === token;
200
+ });
201
+ if (job) {
202
+ Object.assign(job, updates);
203
+ // Emit socket update
204
+ this.emitJobUpdate(lookupKey, job);
205
+ return true;
206
+ }
207
+ return false;
208
+ },
209
+
210
+ /**
211
+ * Cleanup old jobs (safety net - call periodically)
212
+ * @param {number} maxAgeMinutes - Remove jobs older than this (default: 60)
213
+ */
214
+ cleanup: function(maxAgeMinutes) {
215
+ maxAgeMinutes = maxAgeMinutes || 60; // Default 1 hour
216
+ var cutoff = new Date(Date.now() - (maxAgeMinutes * 60 * 1000)).toISOString();
217
+ var removed = 0;
218
+
219
+ for (var lookupKey in this.active) {
220
+ var before = this.active[lookupKey].length;
221
+ this.active[lookupKey] = this.active[lookupKey].filter(function(j) {
222
+ return j.startTime > cutoff;
223
+ });
224
+ removed += (before - this.active[lookupKey].length);
225
+ if (this.active[lookupKey].length === 0) {
226
+ delete this.active[lookupKey];
227
+ }
228
+ }
229
+
230
+ if (removed > 0) {
231
+ console.log('[AiJobs] cleanup: Removed ' + removed + ' stale job(s)');
232
+ }
233
+ return removed;
234
+ },
235
+
236
+ /**
237
+ * Calculate elapsed seconds from startTime
238
+ * @param {string} startTime - ISO timestamp string
239
+ * @returns {number} Elapsed seconds (integer)
240
+ */
241
+ calculateElapsed: function(startTime) {
242
+ if (!startTime) return 0;
243
+ try {
244
+ var start = new Date(startTime);
245
+ var now = new Date();
246
+ var elapsedMs = now - start;
247
+ return Math.floor(elapsedMs / 1000);
248
+ } catch (e) {
249
+ return 0;
250
+ }
251
+ },
252
+
253
+ /**
254
+ * Get all active jobs (for debugging/admin)
255
+ * @returns {object} All active jobs by lookupKey
256
+ */
257
+ getAllActive: function() {
258
+ return this.active;
259
+ },
260
+
261
+ /**
262
+ * HTTP Route Handler: Get all active jobs
263
+ */
264
+ getAllActiveRoute: function(req, res) {
265
+ try {
266
+ var allJobs = [];
267
+ var totalCount = 0;
268
+ for (var lookupKey in AiJobs.active) {
269
+ var jobs = AiJobs.active[lookupKey].filter(function(j) {
270
+ return j.status === 'running' || j.status === 'starting';
271
+ }).map(function(j) {
272
+ // Add elapsed seconds to each job
273
+ var elapsedSeconds = AiJobs.calculateElapsed(j.startTime);
274
+ return Object.assign({}, j, {
275
+ elapsedSeconds: elapsedSeconds
276
+ });
277
+ });
278
+ if (jobs.length > 0) {
279
+ allJobs.push({
280
+ lookupKey: lookupKey,
281
+ jobs: jobs
282
+ });
283
+ totalCount += jobs.length;
284
+ }
285
+ }
286
+ return res.json({
287
+ jobs: allJobs,
288
+ count: totalCount,
289
+ lookupKeys: allJobs.length
290
+ });
291
+ } catch (e) {
292
+ console.error('[AiJobs] getAllActiveRoute error:', e);
293
+ return res.status(500).json({ error: e.message || 'Error getting active jobs' });
294
+ }
295
+ },
296
+
297
+ /**
298
+ * HTTP Route Handler: Get active jobs for specific object
299
+ */
300
+ getActiveForObjectRoute: function(req, res) {
301
+ try {
302
+ var objectId = req.params.objectId;
303
+ if (!objectId) {
304
+ return res.status(400).json({ error: 'Object ID required' });
305
+ }
306
+ var jobs = AiJobs.getActiveJobsForObject(objectId);
307
+ // Add elapsed seconds to each job
308
+ jobs = jobs.map(function(group) {
309
+ return {
310
+ lookupKey: group.lookupKey,
311
+ jobs: (group.jobs || []).map(function(j) {
312
+ var elapsedSeconds = AiJobs.calculateElapsed(j.startTime);
313
+ return Object.assign({}, j, {
314
+ elapsedSeconds: elapsedSeconds
315
+ });
316
+ })
317
+ };
318
+ });
319
+ return res.json({
320
+ jobs: jobs,
321
+ objectId: objectId,
322
+ count: jobs.reduce(function(sum, group) {
323
+ return sum + (group.jobs ? group.jobs.length : 0);
324
+ }, 0)
325
+ });
326
+ } catch (e) {
327
+ console.error('[AiJobs] getActiveForObjectRoute error:', e);
328
+ return res.status(500).json({ error: e.message || 'Error getting jobs for object' });
329
+ }
330
+ },
331
+
332
+ /**
333
+ * HTTP Route Handler: Get active jobs for specific object and field
334
+ */
335
+ getActiveForFieldRoute: function(req, res) {
336
+ try {
337
+ var objectId = req.params.objectId;
338
+ var fieldName = req.params.fieldName;
339
+ if (!objectId || !fieldName) {
340
+ return res.status(400).json({ error: 'Object ID and field name required' });
341
+ }
342
+
343
+ // Lookup key format: {objectId}_{fieldName}
344
+ var lookupKey = objectId + '_' + fieldName;
345
+ var jobs = [];
346
+
347
+ if (AiJobs.active[lookupKey]) {
348
+ jobs = AiJobs.active[lookupKey].filter(function(j) {
349
+ // Return all jobs (including completed) - client will filter for display
350
+ return true;
351
+ }).map(function(j) {
352
+ var elapsedSeconds = AiJobs.calculateElapsed(j.startTime);
353
+ return Object.assign({}, j, {
354
+ elapsedSeconds: elapsedSeconds
355
+ });
356
+ });
357
+ }
358
+
359
+ return res.json({
360
+ jobs: jobs,
361
+ objectId: objectId,
362
+ fieldName: fieldName,
363
+ count: jobs.length
364
+ });
365
+ } catch (e) {
366
+ console.error('[AiJobs] getActiveForFieldRoute error:', e);
367
+ return res.status(500).json({ error: e.message || 'Error getting jobs for field' });
368
+ }
369
+ },
370
+
371
+ /**
372
+ * Initialize routes (called from init.js after Server is ready)
373
+ */
374
+ init: function initAiJobsRoutes() {
375
+ try {
376
+ if (!global.JOE || !JOE.Server) return;
377
+ if (JOE._aiJobsInitialized) return;
378
+ const server = JOE.Server;
379
+ const auth = JOE.auth; // may be undefined if no auth configured
380
+
381
+ // Get all active jobs
382
+ if (auth) {
383
+ server.get('/API/aijobs', auth, function(req, res) {
384
+ return AiJobs.getAllActiveRoute(req, res);
385
+ });
386
+ server.get('/API/aijobs/:objectId', auth, function(req, res) {
387
+ return AiJobs.getActiveForObjectRoute(req, res);
388
+ });
389
+ server.get('/API/aijobs/:objectId/:fieldName', auth, function(req, res) {
390
+ return AiJobs.getActiveForFieldRoute(req, res);
391
+ });
392
+ } else {
393
+ server.get('/API/aijobs', function(req, res) {
394
+ return AiJobs.getAllActiveRoute(req, res);
395
+ });
396
+ server.get('/API/aijobs/:objectId', function(req, res) {
397
+ return AiJobs.getActiveForObjectRoute(req, res);
398
+ });
399
+ server.get('/API/aijobs/:objectId/:fieldName', function(req, res) {
400
+ return AiJobs.getActiveForFieldRoute(req, res);
401
+ });
402
+ }
403
+
404
+ JOE._aiJobsInitialized = true;
405
+ console.log('[AiJobs] routes attached');
406
+ } catch (e) {
407
+ console.log('[AiJobs] init error:', e);
408
+ }
409
+ }
410
+ };
411
+
412
+ module.exports = AiJobs;