json-object-editor 0.10.668 → 0.10.671

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.
@@ -1,2113 +1,2332 @@
1
- const OpenAI = require("openai");
2
- const { google } = require('googleapis');
3
- const path = require('path');
4
- const os = require('os');
5
- const fs = require('fs');
6
- const MCP = require("../modules/MCP.js");
7
- // const { name } = require("json-object-editor/server/webconfig");
8
-
9
- function ChatGPT() {
10
- // const fetch = (await import('node-fetch')).default;
11
- //const openai = new OpenAI();
12
- // Load the service account key JSON file
13
- const serviceAccountKeyFile = path.join(__dirname, '../local-joe-239900-e9e3b447c70e.json');
14
- const google_auth = new google.auth.GoogleAuth({
15
- keyFile: serviceAccountKeyFile,
16
- scopes: ['https://www.googleapis.com/auth/documents.readonly'],
17
- });
18
-
19
- var self = this;
20
- this.async ={};
21
- function coloredLog(message){
22
- try{
23
- // Only emit verbose plugin logs in non‑production environments.
24
- // This keeps consoles clean in production while preserving rich
25
- // traces (assistant resolution, MCP config, systemText) during
26
- // local/dev debugging.
27
- var env = null;
28
- if (typeof JOE !== 'undefined' && JOE && JOE.webconfig && JOE.webconfig.env){
29
- env = JOE.webconfig.env;
30
- } else if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV){
31
- env = process.env.NODE_ENV;
32
- }
33
- if (env && env.toLowerCase() === 'production'){
34
- return;
35
- }
36
- console.log(JOE.Utils.color('[chatgpt]', 'plugin', false), message);
37
- }catch(_e){
38
- // If anything goes wrong determining env, default to logging so
39
- // that development debugging is not silently broken.
40
- try{
41
- console.log('[chatgpt]', message);
42
- }catch(__e){}
43
- }
44
- }
45
- //xx -setup and send a test prompt to chatgpt
46
- //xx get the api key from joe settings
47
-
48
- //get a prompt from id
49
- //send the prompt to chatgpt
50
-
51
- //++get the cotnent of a file
52
- //++send the content of a file to chatgpt
53
-
54
- //++ structure data
55
- //++ save the response to an ai_repsonse
56
- //create an ai_response
57
- //store the content
58
- //attach to the request
59
- //store ids sent with the request
60
- this.default = function(data, req, res) {
61
- try {
62
- var payload = {
63
- params: req.params,
64
- data: data
65
- };
66
- } catch (e) {
67
- return { errors: 'plugin error: ' + e, failedat: 'plugin' };
68
- }
69
- return payload;
70
- };
71
- function getAPIKey() {
72
- const setting = JOE.Utils.Settings('OPENAI_API_KEY');
73
- if (!setting) throw new Error("Missing OPENAI_API_KEY setting");
74
- return setting;
75
- }
76
- function getSchemaDef(name) {
77
- if (!name) return { full: null, summary: null };
78
- const full = JOE.Schemas && JOE.Schemas.schema && JOE.Schemas.schema[name];
79
- const summary = JOE.Schemas && JOE.Schemas.summary && JOE.Schemas.summary[name];
80
- return { full, summary };
81
- }
82
- function buildMcpToolsFromConfig(cfg) {
83
- if (!cfg || !cfg.mcp_enabled) {
84
- return { tools: null, names: [] };
85
- }
86
- try {
87
- const names = MCP.getToolNamesForToolset(
88
- cfg.mcp_toolset || 'read-only',
89
- Array.isArray(cfg.mcp_selected_tools) ? cfg.mcp_selected_tools : null
90
- );
91
- const defs = MCP.getToolDefinitions(names);
92
- return { tools: defs, names: names };
93
- } catch (e) {
94
- console.warn('[chatgpt] buildMcpToolsFromConfig failed', e);
95
- return { tools: null, names: [] };
96
- }
97
- }
98
-
99
- /**
100
- * callMCPTool
101
- *
102
- * Small, well‑scoped helper to invoke a JOE MCP tool directly in‑process,
103
- * without going over HTTP or worrying about POST size limits.
104
- *
105
- * Usage:
106
- * const result = await callMCPTool('listSchemas', {}, { req });
107
- *
108
- * Notes:
109
- * - `toolName` must exist on MCP.tools.
110
- * - `params` should be a plain JSON-serializable object.
111
- * - `ctx` is optional and can pass `{ req }` or other context that MCP
112
- * tools might want (for auth, user, etc.).
113
- */
114
- async function callMCPTool(toolName, params = {}, ctx = {}) {
115
- if (!MCP || !MCP.tools) {
116
- throw new Error("MCP module not initialized; cannot call MCP tool");
117
- }
118
- if (!toolName || typeof toolName !== 'string') {
119
- throw new Error("Missing or invalid MCP tool name");
120
- }
121
- const fn = MCP.tools[toolName];
122
- if (typeof fn !== 'function') {
123
- throw new Error(`MCP tool "${toolName}" not found`);
124
- }
125
- try {
126
- // All MCP tools accept (params, ctx) and return a JSON-serializable result.
127
- // The Responses / tools API often returns arguments as a JSON string, so
128
- // normalize that here before invoking the tool.
129
- let toolParams = params;
130
- if (typeof toolParams === 'string') {
131
- try {
132
- toolParams = JSON.parse(toolParams);
133
- } catch (parseErr) {
134
- console.error(`[chatgpt] Failed to JSON-parse tool arguments for "${toolName}"`, parseErr, toolParams);
135
- // Fall back to passing the raw string so tools that expect it still work.
136
- }
137
- }
138
- const result = await fn(toolParams || {}, ctx || {});
139
- return result;
140
- } catch (e) {
141
- // Surface a clean error upstream but keep details in logs.
142
- console.error(`[chatgpt] MCP tool "${toolName}" error:`, e);
143
- throw new Error(`MCP tool "${toolName}" failed: ${e && e.message || 'Unknown error'}`);
144
- }
145
- }
146
-
147
- /**
148
- * extractToolCalls
149
- *
150
- * Best-effort parser for tool calls from a Responses API result.
151
- * The Responses output shape may evolve; this function looks for
152
- * any "tool_call" typed content in response.output[*].content[*]
153
- * and normalizes it into `{ name, arguments }` objects.
154
- */
155
- function extractToolCalls(response) {
156
- var calls = [];
157
- if (!response || !Array.isArray(response.output)) { return calls; }
158
-
159
- response.output.forEach(function (item) {
160
- if (!item) { return; }
161
- // v1-style: item.type === 'tool_call'
162
- if (item.type === 'function_call') {
163
- calls.push({
164
- name: item.name || item.function_name,
165
- arguments: item.arguments || item.function_arguments || {}
166
- });
167
- }
168
- // message-style: item.content is an array of parts
169
- if (Array.isArray(item.content)) {
170
- item.content.forEach(function (part) {
171
- if (!part) { return; }
172
- if (part.type === 'function_call') {
173
- calls.push({
174
- name: part.name || part.tool_name,
175
- arguments: part.arguments || part.args || {}
176
- });
177
- }
178
- });
179
- }
180
- });
181
-
182
- return calls;
183
- }
184
-
185
- // Detect "request too large / token limit" style errors from the Responses API.
186
- function isTokenLimitError(err) {
187
- if (!err || typeof err !== 'object') return false;
188
- if (err.status !== 429 && err.status !== 400) return false;
189
- const msg = (err.error && err.error.message) || err.message || '';
190
- if (!msg) return false;
191
- const lower = String(msg).toLowerCase();
192
- // Cover common phrasing from OpenAI for context/TPM limits.
193
- return (
194
- lower.includes('request too large') ||
195
- lower.includes('too many tokens') ||
196
- lower.includes('max tokens') ||
197
- lower.includes('maximum context length') ||
198
- lower.includes('tokens per min')
199
- );
200
- }
201
-
202
- // Create a compact representation of a JOE object for use in slim payloads.
203
- function slimJOEObject(item) {
204
- if (!item || typeof item !== 'object') return item;
205
- const name = item.name || item.title || item.label || item.email || item.slug || item._id || '';
206
- const info = item.info || item.description || item.summary || '';
207
- return {
208
- _id: item._id,
209
- itemtype: item.itemtype,
210
- name: name,
211
- info: info
212
- };
213
- }
214
-
215
- // Given an `understandObject` result, produce a slimmed version:
216
- // - keep `object` as-is
217
- // - keep `flattened` for the main object (depth-limited) if present
218
- // - replace each related entry with { field, _id, itemtype, object:{_id,itemtype,name,info} }
219
- // - preserve `schemas`, `tags`, `statuses`, and mark `slim:true`
220
- function slimUnderstandObjectResult(result) {
221
- if (!result || typeof result !== 'object') return result;
222
- const out = {
223
- _id: result._id,
224
- itemtype: result.itemtype,
225
- object: result.object,
226
- // retain main flattened view if available; this is typically much smaller
227
- flattened: result.flattened || null,
228
- schemas: result.schemas || {},
229
- tags: result.tags || {},
230
- statuses: result.statuses || {},
231
- slim: true
232
- };
233
- if (Array.isArray(result.related)) {
234
- out.related = result.related.map(function (rel) {
235
- if (!rel) return rel;
236
- const base = rel.object || {};
237
- const slim = slimJOEObject(base);
238
- return {
239
- field: rel.field,
240
- _id: slim && slim._id || rel._id,
241
- itemtype: slim && slim.itemtype || rel.itemtype,
242
- object: slim
243
- };
244
- });
245
- } else {
246
- out.related = [];
247
- }
248
- return out;
249
- }
250
-
251
- // Walk the messages array and, for any system message containing a JSON payload
252
- // of the form { "tool": "understandObject", "result": {...} }, replace the
253
- // result with a slimmed version to reduce token count. Returns a new array; if
254
- // nothing was changed, returns the original array.
255
- function shrinkUnderstandObjectMessagesForTokens(messages) {
256
- if (!Array.isArray(messages)) return messages;
257
- let changed = false;
258
- const shrunk = messages.map(function (msg) {
259
- if (!msg || msg.role !== 'system') return msg;
260
- if (typeof msg.content !== 'string') return msg;
261
- try {
262
- const parsed = JSON.parse(msg.content);
263
- if (!parsed || parsed.tool !== 'understandObject' || !parsed.result) {
264
- return msg;
265
- }
266
- const slimmed = slimUnderstandObjectResult(parsed.result);
267
- changed = true;
268
- return {
269
- ...msg,
270
- content: JSON.stringify({ tool: 'understandObject', result: slimmed })
271
- };
272
- } catch (_e) {
273
- return msg;
274
- }
275
- });
276
- return changed ? shrunk : messages;
277
- }
278
-
279
- /**
280
- * runWithTools
281
- *
282
- * Single orchestration function for calling the OpenAI Responses API
283
- * with optional tools (sourced from a JOE `ai_assistant`), handling
284
- * tool calls via MCP, and issuing a follow-up model call with the
285
- * tool results injected.
286
- *
287
- * Inputs (opts):
288
- * - openai: OpenAI client instance
289
- * - model: model name to use (e.g. "gpt-4.1-mini", "gpt-5.1")
290
- * - systemText: string of system / instructions text
291
- * - messages: array of { role, content } for the conversation so far
292
- * - assistant: JOE `ai_assistant` object (may contain `tools`)
293
- * - req: Express request (passed into MCP tools as context)
294
- *
295
- * Returns:
296
- * - { response, finalText, messages, toolCalls }
297
- * where `finalText` is the assistant-facing text (from output_text)
298
- * and `messages` is the possibly-extended message list including
299
- * any synthetic `tool` messages.
300
- */
301
- async function runWithTools(opts) {
302
- const openai = opts.openai;
303
- const model = opts.model;
304
- const systemText = opts.systemText || "";
305
- const messages = Array.isArray(opts.messages) ? opts.messages.slice() : [];
306
- const assistant = opts.assistant || null;
307
- const req = opts.req;
308
- const attachmentsMode = opts.attachments_mode || null;
309
- const openaiFileIds = opts.openai_file_ids || null;
310
-
311
- // Debug/trace: log the effective system instructions going into this
312
- // Responses+tools call. This helps verify assistant + MCP instructions
313
- // wiring across prompts, assists, autofill, and widget chat.
314
- try{
315
- coloredLog('runWithTools systemText:\n' + systemText);
316
- }catch(_e){}
317
-
318
- // Normalize tools: manual assistant.tools plus optional MCP tools
319
- let manualTools = null;
320
- if (assistant && assistant.tools) {
321
- if (Array.isArray(assistant.tools)) {
322
- manualTools = assistant.tools;
323
- } else if (typeof assistant.tools === 'string') {
324
- try {
325
- const parsed = JSON.parse(assistant.tools);
326
- if (Array.isArray(parsed)) {
327
- manualTools = parsed;
328
- }
329
- } catch (e) {
330
- console.error('[chatgpt] Failed to parse assistant.tools JSON', e);
331
- }
332
- }
333
- }
334
- // Flatten any Assistants-style function definitions
335
- if (Array.isArray(manualTools)) {
336
- manualTools = manualTools.map(function (t) {
337
- if (t && t.type === 'function' && t.function && !t.name) {
338
- const fn = t.function || {};
339
- return {
340
- type: 'function',
341
- name: fn.name,
342
- description: fn.description,
343
- parameters: fn.parameters || {}
344
- };
345
- }
346
- return t;
347
- });
348
- }
349
-
350
- // Merge manual tools with MCP tools (manual wins on name collisions).
351
- let tools = null;
352
- const mergedByName = {};
353
- const mcp = buildMcpToolsFromConfig(assistant || {});
354
- const mcpTools = Array.isArray(mcp.tools) ? mcp.tools : null;
355
-
356
- if (Array.isArray(manualTools) || Array.isArray(mcpTools)) {
357
- tools = [];
358
- (manualTools || []).forEach(function(t){
359
- if (!t) { return; }
360
- if (t.name) { mergedByName[t.name] = true; }
361
- tools.push(t);
362
- });
363
- (mcpTools || []).forEach(function(t){
364
- if (!t || !t.name) { return; }
365
- if (mergedByName[t.name]) { return; }
366
- tools.push(t);
367
- });
368
- }
369
-
370
- // No tools configured – do a simple single Responses call.
371
- if (!tools) {
372
- const resp = await openai.responses.create({
373
- model: model,
374
- instructions: systemText,
375
- input: messages
376
- });
377
- return {
378
- response: resp,
379
- finalText: resp.output_text || "",
380
- messages: messages,
381
- toolCalls: []
382
- };
383
- }
384
-
385
- // Step 1: call the model with tools enabled.
386
- let firstPayload = {
387
- model: model,
388
- instructions: systemText,
389
- input: messages,
390
- tools: tools,
391
- tool_choice: "auto"
392
- };
393
- if (attachmentsMode && Array.isArray(openaiFileIds) && openaiFileIds.length){
394
- try{
395
- firstPayload = await attachFilesToResponsesPayload(openai, firstPayload, {
396
- attachments_mode: attachmentsMode,
397
- openai_file_ids: openaiFileIds
398
- });
399
- }catch(e){
400
- console.warn('[chatgpt] runWithTools attachments failed; continuing without attachments', e && e.message || e);
401
- }
402
- }
403
- const first = await openai.responses.create(firstPayload);
404
-
405
- const toolCalls = extractToolCalls(first);
406
-
407
- // If the model didn't decide to use tools, just return the first answer.
408
- if (!toolCalls.length) {
409
- return {
410
- response: first,
411
- finalText: first.output_text || "",
412
- messages: messages,
413
- toolCalls: []
414
- };
415
- }
416
-
417
- // Step 2: execute each tool call via MCP and append tool results.
418
- for (let i = 0; i < toolCalls.length; i++) {
419
- const tc = toolCalls[i];
420
- try {
421
- const result = await callMCPTool(tc.name, tc.arguments || {}, { req });
422
- messages.push({
423
- // Responses API does not support a "tool" role in messages.
424
- // We inject tool outputs as a synthetic system message so
425
- // the model can see the results without affecting the
426
- // user/assistant turn structure.
427
- role: "system",
428
- content: JSON.stringify({ tool: tc.name, result: result })
429
- });
430
- } catch (e) {
431
- console.error("[chatgpt] MCP tool error in runWithTools:", e);
432
- messages.push({
433
- role: "system",
434
- content: JSON.stringify({
435
- tool: tc.name,
436
- error: e && e.message || "Tool execution failed"
437
- })
438
- });
439
- }
440
- }
441
-
442
- // Step 3: ask the model again with tool outputs included.
443
- let finalMessages = messages;
444
- let second;
445
- try {
446
- let secondPayload = {
447
- model: model,
448
- instructions: systemText,
449
- input: finalMessages
450
- };
451
- if (attachmentsMode && Array.isArray(openaiFileIds) && openaiFileIds.length){
452
- try{
453
- secondPayload = await attachFilesToResponsesPayload(openai, secondPayload, {
454
- attachments_mode: attachmentsMode,
455
- openai_file_ids: openaiFileIds
456
- });
457
- }catch(e){
458
- console.warn('[chatgpt] runWithTools second-call attachments failed; continuing without attachments', e && e.message || e);
459
- }
460
- }
461
- second = await openai.responses.create(secondPayload);
462
- } catch (e) {
463
- if (isTokenLimitError(e)) {
464
- console.warn("[chatgpt] Responses token limit hit; shrinking understandObject payloads and retrying once");
465
- const shrunk = shrinkUnderstandObjectMessagesForTokens(finalMessages);
466
- // If nothing was shrunk, just rethrow the original error.
467
- if (shrunk === finalMessages) {
468
- throw e;
469
- }
470
- finalMessages = shrunk;
471
- // Retry once with the smaller payload; let any error bubble up.
472
- let retryPayload = {
473
- model: model,
474
- instructions: systemText,
475
- input: finalMessages
476
- };
477
- if (attachmentsMode && Array.isArray(openaiFileIds) && openaiFileIds.length){
478
- try{
479
- retryPayload = await attachFilesToResponsesPayload(openai, retryPayload, {
480
- attachments_mode: attachmentsMode,
481
- openai_file_ids: openaiFileIds
482
- });
483
- }catch(e2){
484
- console.warn('[chatgpt] runWithTools retry attachments failed; continuing without attachments', e2 && e2.message || e2);
485
- }
486
- }
487
- second = await openai.responses.create(retryPayload);
488
- } else {
489
- throw e;
490
- }
491
- }
492
-
493
- return {
494
- response: second,
495
- finalText: second.output_text || "",
496
- messages: finalMessages,
497
- toolCalls: toolCalls
498
- };
499
- }
500
-
501
- // function newClient(){
502
- // var key = getAPIKey();
503
- // var c = new OpenAI({
504
- // apiKey: key, // This is the default and can be omitted
505
- // });
506
- // if(!c || !c.apiKey){
507
- // return { errors: 'No API key provided' };
508
- // }
509
- // return c;
510
- // }
511
- function newClient() {
512
- return new OpenAI({ apiKey: getAPIKey() });
513
- }
514
-
515
- // Safely call Responses API with optional temperature/top_p.
516
- // If the model rejects these parameters, strip and retry once.
517
- async function safeResponsesCreate(openai, payload){
518
- try{
519
- return await openai.responses.create(payload);
520
- }catch(e){
521
- try{
522
- var msg = (e && (e.error && e.error.message) || e.message || '').toLowerCase();
523
- var badTemp = msg.includes("unsupported parameter") && msg.includes("temperature");
524
- var badTopP = msg.includes("unsupported parameter") && msg.includes("top_p");
525
- var unknownTemp = msg.includes("unknown parameter") && msg.includes("temperature");
526
- var unknownTopP = msg.includes("unknown parameter") && msg.includes("top_p");
527
- if (badTemp || badTopP || unknownTemp || unknownTopP){
528
- var p2 = Object.assign({}, payload);
529
- if (p2.hasOwnProperty('temperature')) delete p2.temperature;
530
- if (p2.hasOwnProperty('top_p')) delete p2.top_p;
531
- console.warn('[chatgpt] Retrying without temperature/top_p due to model rejection');
532
- return await openai.responses.create(p2);
533
- }
534
- }catch(_e){ /* fallthrough */ }
535
- throw e;
536
- }
537
- }
538
-
539
- // Ensure a vector store exists with the provided file_ids indexed; returns { vectorStoreId }
540
- async function ensureVectorStoreForFiles(fileIds = []){
541
- const openai = newClient();
542
- // Create ephemeral store per run (could be optimized to reuse/persist later)
543
- const vs = await openai.vectorStores.create({ name: 'JOE Prompt Run '+Date.now() });
544
- const storeId = vs.id;
545
- // Link files by id
546
- for (const fid of (fileIds||[]).slice(0,10)) {
547
- try{
548
- await openai.vectorStores.files.create(storeId, { file_id: fid });
549
- }catch(e){
550
- console.warn('[chatgpt] vectorStores.files.create failed for', fid, e && e.message || e);
551
- }
552
- }
553
- // Poll (best-effort) until files are processed or timeout
554
- const timeoutMs = 8000;
555
- const start = Date.now();
556
- try{
557
- while(Date.now() - start < timeoutMs){
558
- const listed = await openai.vectorStores.files.list(storeId, { limit: 100 });
559
- const items = (listed && listed.data) || [];
560
- const pending = items.some(f => f.status && f.status !== 'completed');
561
- if(!pending){ break; }
562
- await new Promise(r => setTimeout(r, 500));
563
- }
564
- }catch(_e){ /* non-fatal */ }
565
- return { vectorStoreId: storeId };
566
- }
567
-
568
- // ---------------- OpenAI Files helpers ----------------
569
- /**
570
- * attachFilesToResponsesPayload
571
- *
572
- * Shared helper to wire OpenAI `responses.create` payloads with file
573
- * attachments in a consistent way for both MCP and non‑MCP paths.
574
- *
575
- * Modes:
576
- * - attachments_mode === 'file_search':
577
- * - Ensures a temporary vector store via ensureVectorStoreForFiles.
578
- * - Adds a `file_search` tool to payload.tools (if not already present).
579
- * - Sets payload.tool_resources.file_search.vector_store_ids.
580
- * - Leaves payload.input as text/messages.
581
- *
582
- * - attachments_mode === 'direct' (default):
583
- * - Converts the existing `input` string (if any) into an `input_text`
584
- * part and appends up to 10 `{ type:'input_file', file_id }` parts.
585
- * - Sets payload.input = [{ role:'user', content: parts }].
586
- *
587
- * This function is intentionally file‑only; it does not modify instructions
588
- * or other payload fields.
589
- */
590
- async function attachFilesToResponsesPayload(openai, payload, opts){
591
- const mode = (opts && opts.attachments_mode) || 'direct';
592
- const fileIds = (opts && opts.openai_file_ids) || [];
593
- if (!Array.isArray(fileIds) || !fileIds.length) {
594
- return payload;
595
- }
596
- if (mode === 'file_search') {
597
- const ensured = await ensureVectorStoreForFiles(fileIds);
598
- payload.tools = payload.tools || [];
599
- if (!payload.tools.find(function(t){ return t && t.type === 'file_search'; })) {
600
- payload.tools.push({ type:'file_search' });
601
- }
602
- payload.tool_resources = Object.assign({}, payload.tool_resources, {
603
- file_search: { vector_store_ids: [ ensured.vectorStoreId ] }
604
- });
605
- return payload;
606
- }
607
- // Default: direct context stuffing using input_text + input_file parts.
608
- const parts = [];
609
- if (typeof payload.input === 'string' && payload.input.trim().length) {
610
- parts.push({ type:'input_text', text: String(payload.input) });
611
- } else if (Array.isArray(payload.input)) {
612
- // If caller already provided messages as input, preserve them by
613
- // flattening into input_text where possible.
614
- try{
615
- const txt = JSON.stringify(payload.input);
616
- if (txt && txt.length) {
617
- parts.push({ type:'input_text', text: txt });
618
- }
619
- }catch(_e){}
620
- }
621
- fileIds.slice(0, 10).forEach(function(fid){
622
- if (fid) {
623
- parts.push({ type:'input_file', file_id: fid });
624
- }
625
- });
626
- payload.input = [{ role:'user', content: parts }];
627
- return payload;
628
- }
629
- async function uploadFileFromBuffer(buffer, filename, contentType, purpose) {
630
- const openai = newClient();
631
- const usePurpose = purpose || 'assistants';
632
- const tmpDir = os.tmpdir();
633
- const safeName = filename || ('upload_' + Date.now());
634
- const tmpPath = path.join(tmpDir, safeName);
635
- await fs.promises.writeFile(tmpPath, buffer);
636
- try {
637
- // openai.files.create accepts a readable stream
638
- const fileStream = fs.createReadStream(tmpPath);
639
- const created = await openai.files.create({
640
- purpose: usePurpose,
641
- file: fileStream
642
- });
643
- return { id: created.id, purpose: usePurpose };
644
- } finally {
645
- // best-effort cleanup
646
- fs.promises.unlink(tmpPath).catch(()=>{});
647
- }
648
- }
649
-
650
- // Expose a helper that other plugins can call in-process
651
- this.filesUploadFromBufferHelper = async function ({ buffer, filename, contentType, purpose }) {
652
- if (!buffer || !buffer.length) {
653
- throw new Error('Missing buffer');
654
- }
655
- return await uploadFileFromBuffer(buffer, filename, contentType, purpose || 'assistants');
656
- };
657
-
658
- // Public endpoint to retry OpenAI upload from a URL (e.g., S3 object URL)
659
- this.filesRetryFromUrl = async function (data, req, res) {
660
- try {
661
- const { default: got } = await import('got');
662
- const url = data && (data.url || data.location);
663
- const filename = data && data.filename || (url && url.split('/').pop()) || ('upload_' + Date.now());
664
- const contentType = data && data.contentType || undefined;
665
- const purpose = 'assistants';
666
- if (!url) {
667
- return { success: false, error: 'Missing url' };
668
- }
669
- const resp = await got(url, { responseType: 'buffer' });
670
- const buffer = resp.body;
671
- const created = await uploadFileFromBuffer(buffer, filename, contentType, purpose);
672
- return { success: true, openai_file_id: created.id, openai_purpose: created.purpose };
673
- } catch (e) {
674
- return { success: false, error: e && e.message || 'Retry upload failed' };
675
- }
676
- };
677
- this.testPrompt= async function(data, req, res) {
678
- try {
679
- var payload = {
680
- params: req.params,
681
- data: data
682
- };
683
- } catch (e) {
684
- return { errors: 'plugin error: ' + e, failedat: 'plugin' };
685
- }
686
- const client = newClient();
687
- if(client.errors){
688
- return { errors: client.errors };
689
- }
690
- try {
691
- const chatCompletion = await client.chat.completions.create({
692
- messages: [{ role: 'user', content: 'Tell me a story about JOE: the json object editor in under 256 chars.' }],
693
- model: 'gpt-4o',
694
- });
695
- coloredLog(chatCompletion);
696
- const text = chatCompletion.choices && chatCompletion.choices[0] && chatCompletion.choices[0].message && chatCompletion.choices[0].message.content || '';
697
- // Optionally persist as ai_response with parsed JSON when applicable
698
- const parsed = (function(){
699
- try {
700
- const jt = extractJsonText(text);
701
- return jt ? JSON.parse(jt) : null;
702
- } catch(_e){ return null; }
703
- })();
704
- try {
705
- var creator_type = null;
706
- var creator_id = null;
707
- try{
708
- var u = req && req.User;
709
- if (u && u._id){
710
- creator_type = 'user';
711
- creator_id = u._id;
712
- }
713
- }catch(_e){}
714
- const aiResponse = {
715
- itemtype: 'ai_response',
716
- name: 'Test Prompt → ChatGPT',
717
- response_type: 'testPrompt',
718
- response: text,
719
- response_json: parsed,
720
- response_id: chatCompletion.id || '',
721
- user_prompt: payload && payload.data && payload.data.prompt || 'Tell me a story about JOE: the json object editor in under 256 chars.',
722
- model_used: 'gpt-4o',
723
- created: (new Date()).toISOString(),
724
- creator_type: creator_type,
725
- creator_id: creator_id
726
- };
727
- JOE.Storage.save(aiResponse, 'ai_response', function(){}, { history: false, user: (req && req.User) || { name:'system' } });
728
- } catch(_e){ /* best-effort only */ }
729
- return {payload,chatCompletion,content:text};
730
- } catch (error) {
731
- if (error.status === 429) {
732
- return { errors: 'You exceeded your current quota, please check your plan and billing details.' };
733
- } else {
734
- return { errors: 'plugin error: ' + error.message, failedat: 'plugin' };
735
- }
736
- }
737
- }
738
-
739
- this.sendInitialConsultTranscript= async function(data, req, res) {
740
- coloredLog("sendInitialConsultTranscript");
741
- //get the prompt object from the prompt id
742
- //get the business object from the refrenced object id
743
- //see if there is a initial_transcript_url property on that object
744
- //if there is, get the content of the file
745
- //send the content to chatgpt, with the template property of the prompt object
746
- //get the response
747
- try {
748
- var payload = {
749
- params: req.params,
750
- data: data
751
- };
752
- } catch (e) {
753
- return { errors: 'plugin error: ' + e, failedat: 'plugin' };
754
- }
755
- var businessOBJ = JOE.Data.business.find(b=>b._id == data.business);
756
- var promptOBJ = JOE.Data.ai_prompt.find(p=>p._id == data.ai_prompt);
757
-
758
-
759
- // See if there is an initial_transcript_url property on that object
760
- const transcriptUrl = businessOBJ.initial_transcript_url;
761
- if (!transcriptUrl) {
762
- return res.jsonp({ error: 'No initial transcript URL found' });
763
- }
764
-
765
- //Get the content of the file from Google Docs
766
- const transcriptContent = await getGoogleDocContent(transcriptUrl);
767
- if (!transcriptContent || transcriptContent.error) {
768
- return res.jsonp({ error: (transcriptContent.error && transcriptContent.error.message)||'Failed to fetch transcript content' });
769
- }
770
- const tokenCount = countTokens(`${promptOBJ.template}\n\n${transcriptContent}`);
771
- payload.tokenCount = tokenCount;
772
- coloredLog("token count: "+tokenCount);
773
- //return res.jsonp({tokens:tokenCount,content:transcriptContent});
774
- // Send the content to ChatGPT, with the template property of the prompt object
775
- const client = new OpenAI({
776
- apiKey: getAPIKey(), // This is the default and can be omitted
777
- });
778
-
779
- const chatResponse = await client.chat.completions.create({
780
- messages: [{ role: 'user', content: `${promptOBJ.template}\n\n${transcriptContent}` }],
781
- model: 'gpt-4o',
782
- });
783
-
784
- // Get the response
785
- const chatContent = chatResponse.choices[0].message.content;
786
- const responseName = `${businessOBJ.name} - ${promptOBJ.name}`;
787
- // Save the response
788
- await saveAIResponse({
789
- name:responseName,
790
- business: data.business,
791
- ai_prompt: data.ai_prompt,
792
- response: chatContent,
793
- payload,
794
- prompt_method:req.params.method
795
- }, req && req.User);
796
- coloredLog("response saved -"+responseName);
797
- return {payload,
798
- businessOBJ,
799
- promptOBJ,
800
- chatContent,
801
- responseName
802
- };
803
-
804
- }
805
-
806
- async function getGoogleDocContent(docUrl) {
807
- try {
808
- const auth = new google.auth.GoogleAuth({
809
- scopes: ['https://www.googleapis.com/auth/documents.readonly']
810
- });
811
- //get google docs apikey from settings
812
- const GOOGLE_API_KEY = JOE.Utils.Settings('GOOGLE_DOCS_API_KEY');
813
- const docs = google.docs({ version: 'v1', auth:google_auth });
814
- const docId = extractDocIdFromUrl(docUrl);
815
- const doc = await docs.documents.get({ documentId: docId });
816
-
817
- let content = doc.data.body.content.map(element => {
818
- if (element.paragraph && element.paragraph.elements) {
819
- return element.paragraph.elements.map(
820
- e => e.textRun ? e.textRun.content.replace(/Euron Nicholson/g, '[EN]').replace(/\d{2}:\d{2}:\d{2}\.\d{3} --> \d{2}:\d{2}:\d{2}\.\d{3}/g, '-ts-')
821
- : ''
822
- ).join('');
823
- }
824
- return '';
825
- }).join('\n');
826
-
827
- // Remove timestamps and line numbers
828
- //content = content.replace(/^\d+\n\d{2}:\d{2}:\d{2}\.\d{3} --> \d{2}:\d{2}:\d{2}\.\d{3}\n/gm, '');
829
-
830
- return content;
831
- } catch (error) {
832
- console.error('Error fetching Google Doc content:', error);
833
- return {error};
834
- }
835
- }
836
- function countTokens(text, model = 'gpt-4o') {
837
- const enc = encoding_for_model(model);
838
- const tokens = enc.encode(text);
839
- return tokens.length;
840
- }
841
- function extractDocIdFromUrl(url) {
842
- const match = url.match(/\/d\/([a-zA-Z0-9-_]+)/);
843
- return match ? match[1] : null;
844
- }
845
-
846
- async function saveAIResponse(data, user) {
847
- try {
848
- var creator_type = null;
849
- var creator_id = null;
850
- try{
851
- if (user && user._id){
852
- creator_type = 'user';
853
- creator_id = user._id;
854
- }
855
- }catch(_e){}
856
- const aiResponse = {
857
- name: data.name,
858
- itemtype: 'ai_response',
859
- business: data.business,
860
- ai_prompt: data.ai_prompt,
861
- response: data.response,
862
- payload: data.payload,
863
- prompt_method:data.prompt_method,
864
- created: (new Date).toISOString(),
865
- _id:cuid(),
866
- creator_type: creator_type,
867
- creator_id: creator_id
868
- // Add any other fields you want to save
869
- };
870
- await new Promise((resolve, reject) => {
871
- JOE.Storage.save(aiResponse, 'ai_response', function(err, result) {
872
- if (err) {
873
- coloredLog('Error saving AI response: ' + err);
874
- reject(err);
875
- } else {
876
- coloredLog('AI response saved successfully');
877
- resolve(result);
878
- }
879
- });
880
- });
881
- } catch (error) {
882
- coloredLog('Error in saveAIResponse: ' + error);
883
- }
884
- }
885
-
886
- // Normalize model output that should contain JSON. Models often wrap JSON
887
- // in markdown fences (```json ... ```), and may prepend/append prose. This
888
- // helper strips fences and tries to isolate the first well-formed JSON
889
- // object/array substring so JSON.parse has the best chance of succeeding.
890
- // Handles cases where tool call logs are concatenated before the actual JSON.
891
- function extractJsonText(raw) {
892
- if (!raw) { return ''; }
893
- let t = String(raw).trim();
894
- // If there is any ```...``` fenced block, prefer its contents.
895
- const fenceIdx = t.indexOf('```json') !== -1 ? t.indexOf('```json') : t.indexOf('```');
896
- if (fenceIdx !== -1) {
897
- let start = fenceIdx;
898
- const firstNewline = t.indexOf('\n', start);
899
- if (firstNewline !== -1) {
900
- t = t.substring(firstNewline + 1);
901
- } else {
902
- t = t.substring(start + 3);
903
- }
904
- const lastFence = t.lastIndexOf('```');
905
- if (lastFence !== -1) {
906
- t = t.substring(0, lastFence);
907
- }
908
- t = t.trim();
909
- }
910
-
911
- // Handle cases where tool call logs (small JSON objects like {"tool":"..."})
912
- // are concatenated before the actual response JSON (larger JSON object).
913
- // Find all JSON objects and pick the largest one that's not a tool log.
914
- const jsonCandidates = [];
915
- const firstBrace = t.indexOf('{');
916
- const firstBracket = t.indexOf('[');
917
- const lastBrace = Math.max(t.lastIndexOf('}'), t.lastIndexOf(']'));
918
-
919
- if (firstBrace === -1 && firstBracket === -1) {
920
- return '';
921
- }
922
-
923
- const startPos = (firstBrace === -1) ? firstBracket :
924
- ((firstBracket === -1) ? firstBrace : Math.min(firstBrace, firstBracket));
925
-
926
- if (startPos === -1 || lastBrace === -1 || lastBrace <= startPos) {
927
- return t.trim();
928
- }
929
-
930
- // Find all potential JSON objects
931
- for (let i = startPos; i <= lastBrace; i++) {
932
- if (t[i] !== '{' && t[i] !== '[') continue;
933
-
934
- // Find matching closing brace/bracket
935
- let depth = 0;
936
- let inString = false;
937
- let escape = false;
938
- let endPos = -1;
939
-
940
- for (let j = i; j <= lastBrace; j++) {
941
- const char = t[j];
942
- if (escape) {
943
- escape = false;
944
- continue;
945
- }
946
- if (char === '\\') {
947
- escape = true;
948
- continue;
949
- }
950
- if (char === '"') {
951
- inString = !inString;
952
- continue;
953
- }
954
- if (!inString) {
955
- if (char === '{' || char === '[') {
956
- depth++;
957
- } else if (char === '}' || char === ']') {
958
- depth--;
959
- if (depth === 0) {
960
- endPos = j;
961
- break;
962
- }
963
- }
964
- }
965
- }
966
-
967
- if (endPos !== -1) {
968
- const candidate = t.substring(i, endPos + 1);
969
- // Skip tool call logs - they match pattern {"tool":"..."}
970
- const isToolLog = /^\s*{\s*"tool"\s*:/.test(candidate);
971
- try {
972
- JSON.parse(candidate);
973
- jsonCandidates.push({
974
- text: candidate,
975
- length: candidate.length,
976
- isToolLog: isToolLog
977
- });
978
- } catch (e) {
979
- // Not valid JSON, skip
980
- }
981
- }
982
- }
983
-
984
- // Find the largest non-tool-log JSON object, or largest overall if all are tool logs
985
- if (jsonCandidates.length > 0) {
986
- // Filter out tool logs first
987
- const nonToolLogs = jsonCandidates.filter(c => !c.isToolLog);
988
- const candidatesToUse = nonToolLogs.length > 0 ? nonToolLogs : jsonCandidates;
989
-
990
- // Sort by length (descending) and return the largest
991
- candidatesToUse.sort((a, b) => b.length - a.length);
992
- return candidatesToUse[0].text.trim();
993
- }
994
-
995
- // Fallback: try simple first-to-last extraction
996
- if (t[0] !== '{' && t[0] !== '[') {
997
- const first = startPos;
998
- const last = lastBrace;
999
- if (first !== -1 && last !== -1 && last > first) {
1000
- t = t.slice(first, last + 1);
1001
- }
1002
- }
1003
-
1004
- return t.trim();
1005
- }
1006
-
1007
- // Autofill feature (Responses API; supports assistant_id or model)
1008
- this.autofill = async function (data, req, res) {
1009
- const startedAt = Date.now();
1010
- try {
1011
- const body = data || {};
1012
- const objectId = body.object_id || body._id;
1013
- const object = body.object || $J.get(objectId);
1014
- const schemaName = body.schema || (object && object.itemtype) || body.itemtype;
1015
- const { full: schemaFull, summary: schemaSummary } = getSchemaDef(schemaName);
1016
- const rawFields = body.fields || body.field;
1017
- const fields = Array.isArray(rawFields) ? rawFields : (rawFields ? [rawFields] : []);
1018
- const userPrompt = body.prompt || '';
1019
- const assistantId = body.assistant_id || null;
1020
-
1021
- if (!object) {
1022
- return { success: false, error: 'Object not found', code: 'OBJECT_NOT_FOUND' };
1023
- }
1024
- if (!schemaName) {
1025
- return { success: false, error: 'Schema name not determined', code: 'SCHEMA_REQUIRED' };
1026
- }
1027
- if (!fields.length) {
1028
- return { success: false, error: 'No fields specified', code: 'FIELDS_REQUIRED' };
1029
- }
1030
-
1031
- const flattened = JOE.Utils.flattenObject(object._id);
1032
- const systemText = [
1033
- 'You are JOE (Json Object Editor) assistant.',
1034
- 'Task: Populate only the requested fields according to the provided schema context and JOE conventions.',
1035
- '- Respect field types (text, number, arrays, enums, references).',
1036
- '- Do NOT invent IDs for reference fields; only return human text for text-like fields.',
1037
- '- If a field is an enum, choose the closest valid enum. If unsure, omit it from patch.',
1038
- '- If a field is an array, return an array of values.',
1039
- '- Never modify unrelated fields.',
1040
- '- Output MUST be strict JSON with a top-level key "patch" containing only populated fields.',
1041
- '- If you lack sufficient information, return an empty patch.'
1042
- ].join('\\n');
1043
-
1044
- const schemaForContext = schemaSummary || schemaFull || {};
1045
- const userInput = JSON.stringify({
1046
- action: 'autofill_fields',
1047
- target_schema: schemaName,
1048
- requested_fields: fields,
1049
- user_prompt: userPrompt,
1050
- object_context: flattened,
1051
- schema_context: schemaForContext
1052
- }, null, ' ');
1053
-
1054
- const openai = newClient();
1055
- const model = body.model || 'gpt-4o-mini';////'gpt-5-nano';
1056
-
1057
- // Normalize MCP options for autofill. By default, when mcp_enabled is
1058
- // true we expose the read-only toolset, which is safe for field
1059
- // suggestions. Callers can override toolset / selected tools.
1060
- const mcpEnabled = !!body.mcp_enabled;
1061
- const mcpToolset = body.mcp_toolset || 'read-only';
1062
- const mcpSelected = Array.isArray(body.mcp_selected_tools) ? body.mcp_selected_tools : null;
1063
- const mcpInstructionsMode = body.mcp_instructions_mode || 'auto';
1064
-
1065
- let response;
1066
- let mcpToolCalls = [];
1067
- if (mcpEnabled) {
1068
- const toolNames = MCP.getToolNamesForToolset(mcpToolset, mcpSelected);
1069
- const toolsForModel = MCP.getToolDefinitions(toolNames);
1070
- const mcpText = MCP.buildToolInstructions(toolNames, mcpInstructionsMode);
1071
- const systemTextWithMcp = [systemText, mcpText || ''].join('\n').trim();
1072
-
1073
- const messages = [{ role:'user', content:userInput }];
1074
-
1075
- const runResult = await runWithTools({
1076
- openai: openai,
1077
- model: model,
1078
- systemText: systemTextWithMcp,
1079
- messages: messages,
1080
- assistant: { tools: toolsForModel },
1081
- req: req
1082
- });
1083
- response = runResult.response;
1084
- if (runResult && Array.isArray(runResult.toolCalls)) {
1085
- mcpToolCalls = runResult.toolCalls.map(function(tc){
1086
- return {
1087
- name: tc && (tc.name || tc.function_name || tc.tool_name),
1088
- arguments: tc && tc.arguments
1089
- };
1090
- }).filter(function(x){ return x && x.name; });
1091
- }
1092
- } else {
1093
- // For simplicity and robustness, use plain text output and instruct the
1094
- // model to return a strict JSON object. We previously attempted the
1095
- // Responses `json_schema` response_format, but the SDK shape can change
1096
- // and is harder to parse reliably; text + JSON.parse is sufficient here.
1097
- const requestBase = {
1098
- temperature: 0.2,
1099
- instructions: systemText,
1100
- input: userInput
1101
- };
1102
- // Optional web_search tool: if the caller sets allow_web truthy, expose
1103
- // the built-in web_search capability and let the model decide when to
1104
- // call it.
1105
- if (body.allow_web) {
1106
- coloredLog("allowing web search");
1107
- requestBase.tools = [{ type: 'web_search' }];
1108
- requestBase.tool_choice = 'auto';
1109
- }
1110
-
1111
- if (assistantId) {
1112
- response = await openai.responses.create({ assistant_id: assistantId, ...requestBase });
1113
- } else {
1114
- response = await openai.responses.create({ model, ...requestBase });
1115
- }
1116
- }
1117
-
1118
- let textOut = '';
1119
- try { textOut = response.output_text || ''; } catch (_e) {}
1120
- coloredLog("textOut: "+textOut);
1121
- if (!textOut && response && Array.isArray(response.output)) {
1122
- for (let i = 0; i < response.output.length; i++) {
1123
- const item = response.output[i];
1124
- if (item && item.type === 'message' && item.content && Array.isArray(item.content)) {
1125
- const textPart = item.content.find(function (c) { return c.type === 'output_text' || c.type === 'text'; });
1126
- if (textPart && (textPart.text || textPart.output_text)) {
1127
- textOut = textPart.text || textPart.output_text;
1128
- break;
1129
- }
1130
- }
1131
- }
1132
- }
1133
-
1134
- let patch = {};
1135
- try {
1136
- const jsonText = extractJsonText(textOut);
1137
- const parsed = JSON.parse(jsonText || '{}');
1138
- patch = parsed.patch || {};
1139
- } catch (_e) {
1140
- console.warn('[chatgpt.autofill] Failed to parse JSON patch from model output', _e);
1141
- }
1142
- coloredLog("patch: "+JSON.stringify(patch));
1143
- const filteredPatch = {};
1144
- fields.forEach(function (f) {
1145
- if (Object.prototype.hasOwnProperty.call(patch, f)) {
1146
- filteredPatch[f] = patch[f];
1147
- }
1148
- });
1149
- // If we got no fields back on the first attempt, retry once before
1150
- // giving up. Avoid infinite loops by marking a retry flag.
1151
- if (!Object.keys(filteredPatch).length && !body._retry) {
1152
- coloredLog('[autofill] empty patch, retrying once');
1153
- const retryBody = Object.assign({}, body, { _retry: true });
1154
- return await self.autofill(retryBody, req, res);
1155
- }
1156
-
1157
- // Optional save
1158
- let savedItem = null;
1159
- if (body.save_history || body.save_itemtype) {
1160
- const targetItemtype = body.save_itemtype || 'ai_response';
1161
- if (JOE.Schemas && JOE.Schemas.schema && JOE.Schemas.schema[targetItemtype]) {
1162
- const isAiResponse = (targetItemtype === 'ai_response');
1163
- const toolNamesForSave = mcpEnabled ? MCP.getToolNamesForToolset(mcpToolset, mcpSelected) : [];
1164
- const baseSave = {
1165
- itemtype: targetItemtype,
1166
- name: `[${schemaName}] autofill → ${fields.join(', ')}`,
1167
- object_id: object._id,
1168
- target_schema: schemaName,
1169
- fields,
1170
- prompt: userPrompt,
1171
- patch: filteredPatch,
1172
- model,
1173
- raw: { response, mcp_tools_used: mcpToolCalls }
1174
- };
1175
- if (isAiResponse) {
1176
- baseSave.mcp_enabled = mcpEnabled;
1177
- baseSave.mcp_toolset = mcpToolset;
1178
- baseSave.mcp_selected_tools = toolNamesForSave;
1179
- baseSave.mcp_instructions_mode = mcpInstructionsMode;
1180
- baseSave.mcp_tools_used = mcpToolCalls;
1181
- }
1182
- await new Promise(function (resolve) {
1183
- JOE.Storage.save(baseSave, targetItemtype, function (_err, saved) {
1184
- savedItem = saved || null;
1185
- resolve();
1186
- });
1187
- });
1188
- }
1189
- }
1190
-
1191
- return {
1192
- success: true,
1193
- patch: filteredPatch,
1194
- model,
1195
- usage: response && response.usage,
1196
- saved: !!savedItem,
1197
- saved_item: savedItem,
1198
- elapsed_ms: Date.now() - startedAt
1199
- };
1200
- } catch (e) {
1201
- return { success: false, error: e && e.message || 'Unknown error' };
1202
- }
1203
- };
1204
-
1205
- this.getResponse = function(data, req, res) {
1206
- try {
1207
- var prompt = data.prompt;
1208
- if (!prompt) {
1209
- return { error: 'No prompt provided' };
1210
- }
1211
-
1212
- // Simulate a response from ChatGPT
1213
- var response = `ChatGPT response to: ${prompt}`;
1214
- res.jsonp({ response: response });
1215
- return { use_callback: true };
1216
- } catch (e) {
1217
- return { errors: 'plugin error: ' + e, failedat: 'plugin' };
1218
- }
1219
- };
1220
-
1221
- this.html = function(data, req, res) {
1222
- return JSON.stringify(self.default(data, req), '', '\t\r\n <br/>');
1223
- };
1224
- /* NEW AI RESPONSE API*/
1225
-
1226
- this.executeJOEAiPrompt = async function(data, req, res) {
1227
- const referencedObjectIds = []; // Track all objects touched during helper function
1228
- try {
1229
- const promptId = data.ai_prompt;
1230
- // Support both payload shapes: { ai_prompt, params:{...}, ... } and flat
1231
- const params = (data && (data.params || data)) || {};
1232
-
1233
- if (!promptId) {
1234
- return { error: "Missing prompt_id." };
1235
- }
1236
-
1237
- const prompt = await $J.get(promptId); // Use $J.get for consistency
1238
- if (!prompt) {
1239
- return { error: "Prompt not found." };
1240
- }
1241
-
1242
- // If this prompt run is associated with a JOE ai_assistant, log which
1243
- // assistant is being used so we can debug "which agent handled this?"
1244
- try{
1245
- const aiAssistantId = data.ai_assistant_id || null;
1246
- if (aiAssistantId) {
1247
- let asst = null;
1248
- try{
1249
- // Prefer explicit ai_assistant schema lookup, then fallback
1250
- // to a generic get in case datasets are flattened.
1251
- asst = $J.get(aiAssistantId,'ai_assistant') || $J.get(aiAssistantId);
1252
- }catch(_e){}
1253
- const label = asst && (asst.name || asst.title || asst.info || asst._id) || aiAssistantId;
1254
- coloredLog('[prompt] executeJOEAiPrompt using ai_assistant: '
1255
- + label + ' [' + aiAssistantId + ']'
1256
- + ' for prompt: ' + (prompt.name || prompt._id));
1257
- }
1258
- }catch(_e){}
1259
-
1260
- let instructions = prompt.instructions || "";
1261
- let finalInstructions=instructions;
1262
- let finalInput='';
1263
- // Pre-load all content_objects if content_items exist
1264
- const contentObjects = {};
1265
-
1266
- if (prompt.content_items && Array.isArray(prompt.content_items)) {
1267
- for (const content of prompt.content_items) {
1268
- if (params[content.reference]) {
1269
- const obj = $J.get(params[content.reference]);
1270
- if (obj) {
1271
- contentObjects[content.itemtype] = obj;
1272
-
1273
- // Pre-track referenced object
1274
- if (obj._id && !referencedObjectIds.includes(obj._id)) {
1275
- referencedObjectIds.push(obj._id);
1276
- }
1277
- }
1278
- }
1279
- }
1280
- }
1281
-
1282
- // Execute any helper functions if present
1283
- if (prompt.functions) {
1284
- const modFunc = JOE.Utils.requireFromString(prompt.functions, prompt._id);
1285
- const helperResult = await modFunc({
1286
- instructions,
1287
- params,
1288
- ai_prompt: prompt,
1289
- content_objects: contentObjects,
1290
- trackObject: (obj) => {
1291
- if (obj?._id && !referencedObjectIds.includes(obj._id)) {
1292
- referencedObjectIds.push(obj._id);
1293
- }
1294
- }
1295
- });
1296
-
1297
- if (typeof helperResult === 'object' && helperResult.error) {
1298
- return { error: helperResult.error };
1299
- }
1300
-
1301
- // Assume the result is { instructions, input }
1302
- finalInstructions = helperResult.instructions || instructions;
1303
- finalInput = helperResult.input;
1304
- }
1305
-
1306
- // Build a compact uploaded_files header from any referenced objects that
1307
- // have uploader-style files with OpenAI ids. This gives the model
1308
- // explicit metadata about which files were attached and their roles so
1309
- // prompts (like MCP Tokenize Client) can reason about "transcript"
1310
- // vs "summary" sources instead of guessing from content alone.
1311
- let uploadedFilesMeta = [];
1312
- try{
1313
- Object.keys(contentObjects || {}).forEach(function(itemtype){
1314
- const obj = contentObjects[itemtype];
1315
- if (!obj || typeof obj !== 'object') { return; }
1316
- Object.keys(obj).forEach(function(field){
1317
- const val = obj[field];
1318
- if (!Array.isArray(val)) { return; }
1319
- val.forEach(function(f){
1320
- if (f && f.openai_file_id) {
1321
- uploadedFilesMeta.push({
1322
- itemtype: itemtype,
1323
- field: field,
1324
- name: f.filename || '',
1325
- role: f.file_role || null,
1326
- openai_file_id: f.openai_file_id
1327
- });
1328
- }
1329
- });
1330
- });
1331
- });
1332
- }catch(_e){ /* best-effort only */ }
1333
- if (uploadedFilesMeta.length) {
1334
- try{
1335
- const header = { uploaded_files: uploadedFilesMeta };
1336
- if (finalInput && String(finalInput).trim().length) {
1337
- finalInput = JSON.stringify({
1338
- uploaded_files: uploadedFilesMeta,
1339
- input: finalInput
1340
- }, null, 2);
1341
- } else {
1342
- finalInput = JSON.stringify(header, null, 2);
1343
- }
1344
- }catch(_e){ /* if JSON.stringify fails, leave finalInput as-is */ }
1345
- }
1346
-
1347
- const openai = newClient(); // however your OpenAI client is created
1348
-
1349
- // Normalize MCP options from the ai_prompt record.
1350
- const mcpEnabled = !!prompt.mcp_enabled;
1351
- const mcpToolset = prompt.mcp_toolset || 'read-only';
1352
- const mcpSelected = Array.isArray(prompt.mcp_selected_tools) ? prompt.mcp_selected_tools : null;
1353
- const mcpInstructionsMode = prompt.mcp_instructions_mode || 'auto';
1354
-
1355
- // If MCP is enabled, prefer Responses+tools via runWithTools. Otherwise,
1356
- // keep the existing single-call Responses behavior using prompt.tools.
1357
- let response;
1358
- let resolvedToolNames = null;
1359
- let mcpToolCalls = [];
1360
- if (mcpEnabled) {
1361
- // Determine tool names from the configured toolset + overrides.
1362
- const toolNames = MCP.getToolNamesForToolset(mcpToolset, mcpSelected);
1363
- resolvedToolNames = toolNames;
1364
- const toolsForModel = MCP.getToolDefinitions(toolNames);
1365
-
1366
- // Build per-tool MCP instructions (short) and append to the existing instructions.
1367
- const mcpText = MCP.buildToolInstructions(toolNames, mcpInstructionsMode);
1368
- const systemText = [finalInstructions || instructions || '']
1369
- .concat(mcpText ? ['\n', mcpText] : [])
1370
- .join('\n')
1371
- .trim();
1372
-
1373
- const messages = [];
1374
- if (finalInput && String(finalInput).trim().length) {
1375
- messages.push({ role:'user', content:String(finalInput) });
1376
- }
1377
- // Ensure the Responses API always has some input when MCP is enabled.
1378
- // For prompts that rely purely on system instructions, synthesize a
1379
- // minimal user turn so the call remains valid.
1380
- if (!messages.length) {
1381
- messages.push({
1382
- role: 'user',
1383
- content: 'Follow the system instructions above and produce the requested output.'
1384
- });
1385
- }
1386
-
1387
- const runResult = await runWithTools({
1388
- openai: openai,
1389
- model: prompt.ai_model || "gpt-4o",
1390
- systemText: systemText,
1391
- messages: messages,
1392
- // Provide a synthetic assistant-style object so runWithTools can
1393
- // normalize tools into Responses format.
1394
- assistant: { tools: toolsForModel },
1395
- // Pass through attachments so MCP runs see the same files as
1396
- // non‑MCP prompts (direct or file_search modes).
1397
- attachments_mode: prompt.attachments_mode || 'direct',
1398
- openai_file_ids: Array.isArray(data.openai_file_ids) ? data.openai_file_ids : null,
1399
- req: req
1400
- });
1401
- response = runResult.response;
1402
- if (runResult && Array.isArray(runResult.toolCalls)) {
1403
- mcpToolCalls = runResult.toolCalls.map(function(tc){
1404
- return {
1405
- name: tc && (tc.name || tc.function_name || tc.tool_name),
1406
- arguments: tc && tc.arguments
1407
- };
1408
- }).filter(function(x){ return x && x.name; });
1409
- }
1410
- } else {
1411
- const payloadBase = {
1412
- model: prompt.ai_model || "gpt-4o",
1413
- instructions: finalInstructions||instructions, // string only
1414
- input:finalInput||'',
1415
- tools: prompt.tools || [{ "type": "web_search" }],
1416
- tool_choice: prompt.tool_choice || "auto",
1417
- temperature: prompt.temperature ? parseFloat(prompt.temperature) : 0.7,
1418
- //return_token_usage: true
1419
- //max_tokens: prompt.max_tokens ?? 1200
1420
- };
1421
- coloredLog(`${payloadBase.model} and ${payloadBase.temperature}`);
1422
- const mode = (prompt.attachments_mode || 'direct');
1423
- let payload = payloadBase;
1424
- if (Array.isArray(data.openai_file_ids) && data.openai_file_ids.length){
1425
- try{
1426
- payload = await attachFilesToResponsesPayload(openai, payloadBase, {
1427
- attachments_mode: mode,
1428
- openai_file_ids: data.openai_file_ids
1429
- });
1430
- }catch(e){
1431
- console.warn('[chatgpt] attachFilesToResponsesPayload failed; continuing without attachments', e && e.message || e);
1432
- }
1433
- }
1434
- response = await safeResponsesCreate(openai, payload);
1435
- }
1436
-
1437
-
1438
- // const payload = createResponsePayload(prompt, params, instructions, data.user_prompt);
1439
-
1440
- // const response = await openai.chat.completions.create(payload);
1441
-
1442
- // Extract JSON from response to strip tool logs, reasoning text, and other non-JSON content.
1443
- // This is critical for prompts that explicitly require JSON-only output.
1444
- const rawResponseText = response.output_text || "";
1445
- let cleanedResponseText = rawResponseText;
1446
- try {
1447
- const extractedJson = extractJsonText(rawResponseText);
1448
- if (extractedJson && extractedJson.trim().length > 0) {
1449
- // Validate it's actually valid JSON
1450
- JSON.parse(extractedJson);
1451
- cleanedResponseText = extractedJson;
1452
- }
1453
- } catch (e) {
1454
- // If extraction fails or JSON is invalid, fall back to raw text
1455
- // (some prompts may not be JSON-formatted)
1456
- console.warn('[chatgpt.executeJOEAiPrompt] Failed to extract JSON from response, using raw text:', e.message);
1457
- }
1458
-
1459
- const saved = await saveAiResponseRefactor({
1460
- prompt,
1461
- ai_response_content: cleanedResponseText,
1462
- ai_response_raw: rawResponseText,
1463
- user_prompt: finalInput || '',
1464
- params,
1465
- referenced_object_ids: referencedObjectIds,
1466
- response_id:response.id,
1467
- usage: response.usage || {},
1468
- user: req && req.User,
1469
- ai_assistant_id: data.ai_assistant_id,
1470
- mcp_enabled: mcpEnabled,
1471
- mcp_toolset: mcpToolset,
1472
- mcp_selected_tools: resolvedToolNames || (Array.isArray(mcpSelected) ? mcpSelected : []),
1473
- mcp_instructions_mode: mcpInstructionsMode,
1474
- mcp_tools_used: mcpToolCalls
1475
- });
1476
- try{
1477
- if (saved && Array.isArray(data.openai_file_ids) && data.openai_file_ids.length){
1478
- saved.used_openai_file_ids = data.openai_file_ids.slice(0,10);
1479
- await new Promise(function(resolve){
1480
- JOE.Storage.save(saved,'ai_response',function(){ resolve(); },{ user: req && req.User, history:false });
1481
- });
1482
- }
1483
- }catch(_e){}
1484
-
1485
- return { success: true, ai_response_id: saved._id,response:cleanedResponseText,usage:response.usage };
1486
- } catch (e) {
1487
- console.error('❌ executeJOEAiPrompt error:', e);
1488
- return { error: "Failed to execute AI prompt.",message: e.message };
1489
- }
1490
- };
1491
-
1492
- function createResponsePayload(prompt, params, instructions, user_prompt) {
1493
- return {
1494
- model: prompt.model || "gpt-4o",
1495
- messages: [
1496
- { role: "system", content: instructions },
1497
- { role: "user", content: user_prompt || "" }
1498
- ],
1499
- tools: prompt.tools || undefined,
1500
- tool_choice: prompt.tool_choice || "auto",
1501
- temperature: prompt.temperature ?? 0.7,
1502
- max_tokens: prompt.max_tokens ?? 1200
1503
- };
1504
- }
1505
- async function saveAiResponseRefactor({ prompt, ai_response_content, ai_response_raw, user_prompt, params, referenced_object_ids,response_id,usage,user,ai_assistant_id, mcp_enabled, mcp_toolset, mcp_selected_tools, mcp_instructions_mode, mcp_tools_used }) {
1506
- var response_keys = [];
1507
- try {
1508
- response_keys = Object.keys(JSON.parse(ai_response_content));
1509
- }catch (e) {
1510
- console.error('❌ Error parsing AI response content for keys:', e);
1511
- }
1512
- // Best-effort parse into JSON for downstream agents (Thought pipeline, etc.)
1513
- let parsedResponse = null;
1514
- try {
1515
- const jt = extractJsonText(ai_response_content);
1516
- if (jt) {
1517
- parsedResponse = JSON.parse(jt);
1518
- }
1519
- } catch(_e) {
1520
- parsedResponse = null;
1521
- }
1522
- var creator_type = null;
1523
- var creator_id = null;
1524
- try{
1525
- if (ai_assistant_id){
1526
- creator_type = 'ai_assistant';
1527
- creator_id = ai_assistant_id;
1528
- } else if (user && user._id){
1529
- creator_type = 'user';
1530
- creator_id = user._id;
1531
- }
1532
- }catch(_e){}
1533
- const aiResponse = {
1534
- name: `${prompt.name}`,
1535
- itemtype: 'ai_response',
1536
- ai_prompt: prompt._id,
1537
- prompt_name: prompt.name,
1538
- prompt_method:prompt.prompt_method,
1539
- response: ai_response_content,
1540
- response_raw: ai_response_raw || null,
1541
- response_json: parsedResponse,
1542
- response_keys: response_keys,
1543
- response_id:response_id||'',
1544
- user_prompt: user_prompt,
1545
- params_used: params,
1546
- usage: usage || {},
1547
- tags: prompt.tags || [],
1548
- model_used: prompt.ai_model || "gpt-4o",
1549
- referenced_objects: referenced_object_ids, // new flexible array of referenced object ids
1550
- created: (new Date).toISOString(),
1551
- _id: cuid(),
1552
- creator_type: creator_type,
1553
- creator_id: creator_id
1554
- };
1555
- // Only attach MCP metadata when MCP was actually enabled for this run, to
1556
- // avoid introducing nulls into history diffs.
1557
- try{
1558
- if (mcp_enabled) {
1559
- aiResponse.mcp_enabled = true;
1560
- if (mcp_toolset) { aiResponse.mcp_toolset = mcp_toolset; }
1561
- if (Array.isArray(mcp_selected_tools) && mcp_selected_tools.length) {
1562
- aiResponse.mcp_selected_tools = mcp_selected_tools;
1563
- }
1564
- if (mcp_instructions_mode) {
1565
- aiResponse.mcp_instructions_mode = mcp_instructions_mode;
1566
- }
1567
- if (Array.isArray(mcp_tools_used) && mcp_tools_used.length) {
1568
- aiResponse.mcp_tools_used = mcp_tools_used;
1569
- }
1570
- }
1571
- }catch(_e){}
1572
-
1573
- await new Promise((resolve, reject) => {
1574
- JOE.Storage.save(aiResponse, 'ai_response', function(err, result) {
1575
- if (err) {
1576
- console.error('❌ Error saving AI response:', err);
1577
- reject(err);
1578
- } else {
1579
- console.log('✅ AI response saved successfully');
1580
- resolve(result);
1581
- }
1582
- });
1583
- });
1584
-
1585
- return aiResponse;
1586
- }
1587
-
1588
- // ---------- Widget chat endpoints (Responses API + optional assistants) ----------
1589
- function normalizeMessages(messages) {
1590
- if (!Array.isArray(messages)) { return []; }
1591
- return messages.map(function (m) {
1592
- return {
1593
- role: m.role || 'assistant',
1594
- content: m.content || '',
1595
- created_at: m.created_at || m.created || new Date().toISOString()
1596
- };
1597
- });
1598
- }
1599
-
1600
- /**
1601
- * widgetStart
1602
- *
1603
- * Purpose:
1604
- * Create and persist a new `ai_widget_conversation` record for the
1605
- * external `<joe-ai-widget>` chat component. This is a lightweight
1606
- * conversation record that stores model, assistant, system text and
1607
- * messages for the widget.
1608
- *
1609
- * Inputs (data):
1610
- * - model (optional) override model for the widget
1611
- * - ai_assistant_id (optional) JOE ai_assistant cuid
1612
- * - system (optional) explicit system text
1613
- * - source (optional) freeform source tag, defaults to "widget"
1614
- *
1615
- * OpenAI calls:
1616
- * - None. This endpoint only touches storage.
1617
- *
1618
- * Output:
1619
- * - { success, conversation_id, model, assistant_id }
1620
- * where assistant_id is the OpenAI assistant_id (if present).
1621
- */
1622
- this.widgetStart = async function (data, req, res) {
1623
- try {
1624
- var body = data || {};
1625
- // Default to a modern chat model when no assistant/model is provided.
1626
- // If an assistant is supplied, its ai_model will override this.
1627
- var model = body.model || "gpt-5.1";
1628
- var assistant = body.ai_assistant_id ? $J.get(body.ai_assistant_id) : null;
1629
- var system = body.system || (assistant && assistant.instructions) || "";
1630
- // Prefer explicit user fields coming from the client (ai-widget-test page
1631
- // passes _joe.User fields). Widget endpoints no longer infer from req.User
1632
- // to keep a single, explicit source of truth.
1633
- var user = null;
1634
- if (body.user_id || body.user_name || body.user_color) {
1635
- user = {
1636
- _id: body.user_id,
1637
- name: body.user_name,
1638
- fullname: body.user_name,
1639
- color: body.user_color
1640
- };
1641
- }
1642
- var user_color = (body.user_color) || (user && user.color) || null;
1643
-
1644
- var convo = {
1645
- _id: (typeof cuid === 'function') ? cuid() : undefined,
1646
- itemtype: "ai_widget_conversation",
1647
- model: (assistant && assistant.ai_model) || model,
1648
- assistant: assistant && assistant._id,
1649
- assistant_id: assistant && assistant.assistant_id,
1650
- assistant_color: assistant && assistant.assistant_color,
1651
- user: user && user._id,
1652
- user_name: user && (user.fullname || user.name),
1653
- user_color: user_color,
1654
- system: system,
1655
- messages: [],
1656
- source: body.source || "widget",
1657
- // Optional scope for object-scoped widget chats
1658
- scope_itemtype: body.scope_itemtype || null,
1659
- scope_id: body.scope_id || null,
1660
- created: new Date().toISOString(),
1661
- joeUpdated: new Date().toISOString()
1662
- };
1663
- if (body.name && !convo.name) {
1664
- convo.name = String(body.name);
1665
- }
1666
-
1667
- const saved = await new Promise(function (resolve, reject) {
1668
- // Widget conversations are lightweight and do not need full history diffs.
1669
- JOE.Storage.save(convo, "ai_widget_conversation", function (err, result) {
1670
- if (err) return reject(err);
1671
- resolve(result);
1672
- }, { history: false });
1673
- });
1674
-
1675
- return {
1676
- success: true,
1677
- conversation_id: saved._id,
1678
- model: saved.model,
1679
- assistant_id: saved.assistant_id || null,
1680
- assistant_color: saved.assistant_color || null,
1681
- user_color: saved.user_color || user_color || null
1682
- };
1683
- } catch (e) {
1684
- console.error("[chatgpt] widgetStart error:", e);
1685
- return { success: false, error: e && e.message || "Unknown error" };
1686
- }
1687
- };
1688
-
1689
- /**
1690
- * widgetHistory
1691
- *
1692
- * Purpose:
1693
- * Load an existing `ai_widget_conversation` and normalize its
1694
- * messages for use by `<joe-ai-widget>` on page load or refresh.
1695
- *
1696
- * Inputs (data):
1697
- * - conversation_id or _id: the widget conversation cuid
1698
- *
1699
- * OpenAI calls:
1700
- * - None. Purely storage + normalization.
1701
- *
1702
- * Output:
1703
- * - { success, conversation_id, model, assistant_id, messages }
1704
- */
1705
- this.widgetHistory = async function (data, req, res) {
1706
- try {
1707
- var conversation_id = data.conversation_id || data._id;
1708
- if (!conversation_id) {
1709
- return { success: false, error: "Missing conversation_id" };
1710
- }
1711
- const convo = await new Promise(function (resolve, reject) {
1712
- JOE.Storage.load("ai_widget_conversation", { _id: conversation_id }, function (err, results) {
1713
- if (err) return reject(err);
1714
- resolve(results && results[0]);
1715
- });
1716
- });
1717
- if (!convo) {
1718
- return { success: false, error: "Conversation not found" };
1719
- }
1720
-
1721
- convo.messages = normalizeMessages(convo.messages);
1722
- return {
1723
- success: true,
1724
- conversation_id: convo._id,
1725
- model: convo.model,
1726
- assistant_id: convo.assistant_id || null,
1727
- assistant_color: convo.assistant_color || null,
1728
- user_color: convo.user_color || null,
1729
- messages: convo.messages
1730
- };
1731
- } catch (e) {
1732
- console.error("[chatgpt] widgetHistory error:", e);
1733
- return { success: false, error: e && e.message || "Unknown error" };
1734
- }
1735
- };
1736
-
1737
- /**
1738
- * widgetMessage
1739
- *
1740
- * Purpose:
1741
- * Handle a single user turn for `<joe-ai-widget>`:
1742
- * - Append the user message to the stored conversation.
1743
- * - Call OpenAI Responses (optionally with tools from the selected
1744
- * `ai_assistant`, via runWithTools + MCP).
1745
- * - Append the assistant reply, persist the conversation, and return
1746
- * the full message history plus the latest assistant message.
1747
- *
1748
- * Inputs (data):
1749
- * - conversation_id or _id: cuid of the widget conversation
1750
- * - content: user text
1751
- * - role: user role, defaults to "user"
1752
- * - assistant_id: optional OpenAI assistant_id (used only to
1753
- * locate the JOE ai_assistant config)
1754
- * - model: optional model override
1755
- *
1756
- * OpenAI calls:
1757
- * - responses.create (once if no tools; twice when tools are present):
1758
- * * First call may include tools (assistant.tools) and `tool_choice:"auto"`.
1759
- * * Any tool calls are executed via MCP and injected as `tool` messages.
1760
- * * Second call is plain Responses with updated messages.
1761
- *
1762
- * Output:
1763
- * - { success, conversation_id, model, assistant_id, messages,
1764
- * last_message, usage }
1765
- */
1766
- this.widgetMessage = async function (data, req, res) {
1767
- try {
1768
- var body = data || {};
1769
- var conversation_id = body.conversation_id || body._id;
1770
- var content = body.content;
1771
- var role = body.role || "user";
1772
-
1773
- if (!conversation_id || !content) {
1774
- return { success: false, error: "Missing conversation_id or content" };
1775
- }
1776
-
1777
- const convo = await new Promise(function (resolve, reject) {
1778
- JOE.Storage.load("ai_widget_conversation", { _id: conversation_id }, function (err, results) {
1779
- if (err) return reject(err);
1780
- resolve(results && results[0]);
1781
- });
1782
- });
1783
- if (!convo) {
1784
- return { success: false, error: "Conversation not found" };
1785
- }
1786
-
1787
- // Best-effort: if this is an object-scoped conversation and we have
1788
- // not yet attached any files, walk the scoped object for uploader
1789
- // style files that have OpenAI ids and cache them on the convo.
1790
- try{
1791
- if ((!convo.attached_openai_file_ids || !convo.attached_openai_file_ids.length) &&
1792
- convo.scope_itemtype && convo.scope_id) {
1793
- var scopedObj = null;
1794
- try{
1795
- scopedObj = $J.get(convo.scope_id, convo.scope_itemtype) || $J.get(convo.scope_id);
1796
- }catch(_e){}
1797
- if (scopedObj && typeof scopedObj === 'object') {
1798
- var ids = [];
1799
- var meta = [];
1800
- Object.keys(scopedObj).forEach(function(field){
1801
- var val = scopedObj[field];
1802
- if (!Array.isArray(val)) { return; }
1803
- val.forEach(function(f){
1804
- if (f && f.openai_file_id) {
1805
- ids.push(f.openai_file_id);
1806
- meta.push({
1807
- itemtype: scopedObj.itemtype || convo.scope_itemtype,
1808
- field: field,
1809
- name: f.filename || '',
1810
- role: f.file_role || null,
1811
- openai_file_id: f.openai_file_id
1812
- });
1813
- }
1814
- });
1815
- });
1816
- if (ids.length) {
1817
- convo.attached_openai_file_ids = ids;
1818
- convo.attached_files_meta = meta;
1819
- }
1820
- }
1821
- }
1822
- }catch(_e){ /* non-fatal */ }
1823
-
1824
- convo.messages = normalizeMessages(convo.messages);
1825
-
1826
- // On the very first turn of an object-scoped widget conversation,
1827
- // pre-load a slimmed understandObject snapshot so the assistant
1828
- // immediately knows which record "this client/task/..." refers to
1829
- // without having to remember to call MCP. We keep this snapshot
1830
- // concise via slimUnderstandObjectResult and only inject it once.
1831
- try{
1832
- var isObjectChat = (convo.source === 'object_chat') && convo.scope_id;
1833
- var hasMessages = Array.isArray(convo.messages) && convo.messages.length > 0;
1834
- if (isObjectChat && !hasMessages){
1835
- const uo = await callMCPTool('understandObject', {
1836
- _id: convo.scope_id,
1837
- itemtype: convo.scope_itemtype || undefined,
1838
- depth: 1,
1839
- slim: true
1840
- }, { req });
1841
- const slimmed = slimUnderstandObjectResult(uo);
1842
- if (slimmed) {
1843
- convo.messages = convo.messages || [];
1844
- convo.messages.push({
1845
- role: 'system',
1846
- content: JSON.stringify({
1847
- tool: 'understandObject',
1848
- scope_object: slimmed
1849
- })
1850
- });
1851
- }
1852
- }
1853
- }catch(_e){
1854
- console.warn('[chatgpt] widgetMessage understandObject preload failed', _e && _e.message || _e);
1855
- }
1856
-
1857
- const nowIso = new Date().toISOString();
1858
-
1859
- // Append user message
1860
- const userMsg = { role: role, content: content, created_at: nowIso };
1861
- convo.messages.push(userMsg);
1862
-
1863
- // Backfill user metadata (id/name/color) on older conversations that
1864
- // were created before we started storing these fields. Prefer explicit
1865
- // body fields only; we no longer infer from req.User so that widget
1866
- // calls always have a single, explicit user source.
1867
- var u = null;
1868
- if (body.user_id || body.user_name || body.user_color) {
1869
- u = {
1870
- _id: body.user_id,
1871
- name: body.user_name,
1872
- fullname: body.user_name,
1873
- color: body.user_color
1874
- };
1875
- }
1876
- if (u) {
1877
- if (!convo.user && u._id) {
1878
- convo.user = u._id;
1879
- }
1880
- if (!convo.user_name && (u.fullname || u.name)) {
1881
- convo.user_name = u.fullname || u.name;
1882
- }
1883
- if (!convo.user_color && u.color) {
1884
- convo.user_color = u.color;
1885
- }
1886
- }
1887
-
1888
- // Resolve the JOE ai_assistant driving this conversation. We support
1889
- // both the modern flow (ai_assistant_id / convo.assistant, which are
1890
- // JOE cuid references) and the legacy OpenAI Assistants flow
1891
- // (assistant_id / convo.assistant_id, which are OpenAI ids).
1892
- var assistantObj = null;
1893
- var joeAssistantId = body.ai_assistant_id || convo.assistant || null; // JOE cuid
1894
- if (joeAssistantId) {
1895
- try{
1896
- // Prefer a direct lookup via the ai_assistant schema, but fall
1897
- // back to scanning the in-memory dataset if needed. In some
1898
- // server contexts $J.get may not be wired for ai_assistant yet,
1899
- // while JOE.Data.ai_assistant is available.
1900
- assistantObj = $J.get(joeAssistantId,'ai_assistant') || $J.get(joeAssistantId) || null;
1901
- }catch(_e){}
1902
- if (!assistantObj && JOE && JOE.Data && Array.isArray(JOE.Data.ai_assistant)) {
1903
- assistantObj = JOE.Data.ai_assistant.find(function(a){
1904
- return a && (a._id === joeAssistantId);
1905
- }) || null;
1906
- }
1907
- }
1908
- const assistantId = body.assistant_id || convo.assistant_id || (assistantObj && assistantObj.assistant_id) || null;
1909
- // Legacy fallback: if we only have an OpenAI assistant_id, try to
1910
- // locate the JOE ai_assistant by that id.
1911
- if (!assistantObj && assistantId && JOE && JOE.Data && Array.isArray(JOE.Data.ai_assistant)) {
1912
- assistantObj = JOE.Data.ai_assistant.find(function (a) {
1913
- return a && a.assistant_id === assistantId;
1914
- }) || null;
1915
- }
1916
-
1917
- // Log which ai_assistant (if any) is being used for this widget
1918
- // conversation so we can easily confirm the active agent when
1919
- // debugging object chat vs AI Hub behavior.
1920
- try{
1921
- coloredLog('[widget] assistant resolution: '
1922
- + 'convo.assistant=' + String(convo.assistant || '')
1923
- + ' convo.assistant_id=' + String(convo.assistant_id || '')
1924
- + ' body.ai_assistant_id=' + String(body.ai_assistant_id || '')
1925
- + ' body.assistant_id=' + String(body.assistant_id || '')
1926
- + ' resolvedJoe=' + String(assistantObj && assistantObj._id || ''));
1927
- if (assistantObj) {
1928
- var asstLabel = assistantObj.name || assistantObj.title || assistantObj.info || assistantObj._id || assistantId;
1929
- coloredLog('[widget] widgetMessage using ai_assistant: '
1930
- + asstLabel + ' [' + (assistantObj._id || assistantId || '') + ']'
1931
- + ' source=' + String(convo.source || 'widget')
1932
- + ' convo=' + String(convo._id || ''));
1933
- } else if (assistantId) {
1934
- coloredLog('[widget] widgetMessage assistant_id (no JOE ai_assistant found): '
1935
- + assistantId
1936
- + ' source=' + String(convo.source || 'widget')
1937
- + ' convo=' + String(convo._id || ''));
1938
- }
1939
- }catch(_e){}
1940
-
1941
- const openai = newClient();
1942
- const model = (assistantObj && assistantObj.ai_model) || convo.model || body.model || "gpt-5.1";
1943
-
1944
- // Prefer explicit system text on the conversation, then assistant instructions.
1945
- const baseSystemText = (convo.system && String(convo.system)) ||
1946
- (assistantObj && assistantObj.instructions) ||
1947
- "";
1948
-
1949
- // When this conversation was launched from an object ("Start Chat"
1950
- // on a record), include a small scope hint so the assistant knows
1951
- // which object id/itemtype to use with MCP tools like
1952
- // understandObject/search. We keep this concise to avoid
1953
- // unnecessary tokens but still make the scope unambiguous.
1954
- let systemText = baseSystemText;
1955
- try{
1956
- if (convo.source === 'object_chat' && convo.scope_id) {
1957
- const scopeLine = '\n\n---\nJOE scope_object:\n'
1958
- + '- itemtype: ' + String(convo.scope_itemtype || 'unknown') + '\n'
1959
- + '- _id: ' + String(convo.scope_id) + '\n'
1960
- + 'When you need this object\'s details, call the MCP tool "understandObject" '
1961
- + 'with these identifiers, or search for related records using the MCP search tools.\n';
1962
- systemText = (baseSystemText || '') + scopeLine;
1963
- }
1964
- }catch(_e){ /* non-fatal */ }
1965
-
1966
- // Append MCP tool instructions for assistants that have MCP enabled,
1967
- // using the same helper as other MCP-aware surfaces.
1968
- try{
1969
- if (assistantObj && assistantObj.mcp_enabled) {
1970
- const mcpCfg = {
1971
- mcp_enabled: assistantObj.mcp_enabled,
1972
- mcp_toolset: assistantObj.mcp_toolset,
1973
- mcp_selected_tools: assistantObj.mcp_selected_tools,
1974
- mcp_instructions_mode: assistantObj.mcp_instructions_mode || 'auto'
1975
- };
1976
- const mcp = buildMcpToolsFromConfig(mcpCfg);
1977
- coloredLog('[widget] MCP config for assistant '
1978
- + String(assistantObj._id || '') + ': enabled=' + String(mcpCfg.mcp_enabled)
1979
- + ' toolset=' + String(mcpCfg.mcp_toolset || '')
1980
- + ' names=' + JSON.stringify(mcp.names || []));
1981
- if (mcp.names && mcp.names.length) {
1982
- const txt = MCP.buildToolInstructions(mcp.names, mcpCfg.mcp_instructions_mode || 'auto');
1983
- if (txt) {
1984
- coloredLog('[widget] appending MCP instructions block to systemText');
1985
- systemText = (systemText || '') + '\n\n' + txt;
1986
- }
1987
- }
1988
- } else {
1989
- coloredLog('[widget] MCP disabled for this assistant or assistant missing; no MCP block appended.');
1990
- }
1991
- }catch(_e){ /* non-fatal */ }
1992
-
1993
- // Build the messages array for the model. We deliberately separate
1994
- // the stored `convo.messages` from the model-facing payload so we
1995
- // can annotate the latest user turn with uploaded_files metadata
1996
- // without altering the persisted history.
1997
- const messagesForModel = convo.messages.map(function (m) {
1998
- return { role: m.role, content: m.content };
1999
- });
2000
- // If we have attached file metadata, wrap the latest user turn in a
2001
- // small JSON envelope so the model can see which files exist and how
2002
- // they are labeled (role, name, origin field) while still receiving
2003
- // the raw user input as `input`.
2004
- try{
2005
- if (convo.attached_files_meta && convo.attached_files_meta.length && messagesForModel.length) {
2006
- var lastMsg = messagesForModel[messagesForModel.length - 1];
2007
- if (lastMsg && lastMsg.role === role && typeof lastMsg.content === 'string') {
2008
- lastMsg.content = JSON.stringify({
2009
- uploaded_files: convo.attached_files_meta,
2010
- input: lastMsg.content
2011
- }, null, 2);
2012
- }
2013
- }
2014
- }catch(_e){ /* non-fatal */ }
2015
-
2016
- // Collect OpenAI file ids from scoped object attachments and any
2017
- // assistant-level files so they are available to the model via the
2018
- // shared attachFilesToResponsesPayload helper inside runWithTools.
2019
- var openaiFileIds = [];
2020
- if (Array.isArray(convo.attached_openai_file_ids) && convo.attached_openai_file_ids.length){
2021
- openaiFileIds = openaiFileIds.concat(convo.attached_openai_file_ids);
2022
- }
2023
- try{
2024
- if (assistantObj && Array.isArray(assistantObj.assistant_files)) {
2025
- assistantObj.assistant_files.forEach(function(f){
2026
- if (f && f.openai_file_id) {
2027
- openaiFileIds.push(f.openai_file_id);
2028
- }
2029
- });
2030
- }
2031
- }catch(_e){}
2032
-
2033
- // Use runWithTools so that, when an assistant has tools configured,
2034
- // we let the model call those tools via MCP / function tools before
2035
- // generating a final response. Attach any discovered OpenAI files
2036
- // so the model can read from them as needed.
2037
- const runResult = await runWithTools({
2038
- openai: openai,
2039
- model: model,
2040
- systemText: systemText,
2041
- messages: messagesForModel,
2042
- assistant: assistantObj,
2043
- attachments_mode: (body.attachments_mode || 'direct'),
2044
- openai_file_ids: openaiFileIds.length ? openaiFileIds : null,
2045
- req: req
2046
- });
2047
-
2048
- // If tools were called this turn, inject a small meta message so the
2049
- // widget clearly shows which functions ran before the assistant reply.
2050
- if (runResult.toolCalls && runResult.toolCalls.length) {
2051
- const names = runResult.toolCalls.map(function (tc) { return tc && tc.name; })
2052
- .filter(Boolean)
2053
- .join(', ');
2054
- convo.messages.push({
2055
- role: "assistant",
2056
- meta: "tools_used",
2057
- content: "[Tools used this turn: " + names + "]",
2058
- created_at: nowIso
2059
- });
2060
- }
2061
-
2062
- const assistantText = runResult.finalText || "";
2063
- const assistantMsg = {
2064
- role: "assistant",
2065
- content: assistantText,
2066
- created_at: new Date().toISOString()
2067
- };
2068
- convo.messages.push(assistantMsg);
2069
- convo.last_message_at = assistantMsg.created_at;
2070
- convo.joeUpdated = assistantMsg.created_at;
2071
-
2072
- await new Promise(function (resolve, reject) {
2073
- // Skip history for widget conversations to avoid heavy diffs / craydent.equals issues.
2074
- JOE.Storage.save(convo, "ai_widget_conversation", function (err, saved) {
2075
- if (err) return reject(err);
2076
- resolve(saved);
2077
- }, { history: false });
2078
- });
2079
-
2080
- return {
2081
- success: true,
2082
- conversation_id: convo._id,
2083
- model: model,
2084
- assistant_id: assistantId,
2085
- assistant_color: (assistantObj && assistantObj.assistant_color) || convo.assistant_color || null,
2086
- user_color: convo.user_color || ((u && u.color) || null),
2087
- messages: convo.messages,
2088
- last_message: assistantMsg,
2089
- // Usage comes from the underlying Responses call inside runWithTools.
2090
- usage: (runResult.response && runResult.response.usage) || {}
2091
- };
2092
- } catch (e) {
2093
- console.error("[chatgpt] widgetMessage error:", e);
2094
- return { success: false, error: e && e.message || "Unknown error" };
2095
- }
2096
- };
2097
-
2098
- // Mark async plugin methods so Server.pluginHandling will await them.
2099
- this.async = {
2100
- executeJOEAiPrompt: this.executeJOEAiPrompt,
2101
- testPrompt: this.testPrompt,
2102
- sendInitialConsultTranscript: this.sendInitialConsultTranscript,
2103
- widgetStart: this.widgetStart,
2104
- widgetHistory: this.widgetHistory,
2105
- widgetMessage: this.widgetMessage,
2106
- autofill: this.autofill,
2107
- filesRetryFromUrl: this.filesRetryFromUrl
2108
- };
2109
- this.protected = [,'testPrompt'];
2110
- return self;
2111
- }
2112
-
2113
- module.exports = new ChatGPT();
1
+ const OpenAI = require("openai");
2
+ const { google } = require('googleapis');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const fs = require('fs');
6
+ const MCP = require("../modules/MCP.js");
7
+ // const { name } = require("json-object-editor/server/webconfig");
8
+
9
+ function ChatGPT() {
10
+ // const fetch = (await import('node-fetch')).default;
11
+ //const openai = new OpenAI();
12
+ // Load the service account key JSON file
13
+ const serviceAccountKeyFile = path.join(__dirname, '../local-joe-239900-e9e3b447c70e.json');
14
+ const google_auth = new google.auth.GoogleAuth({
15
+ keyFile: serviceAccountKeyFile,
16
+ scopes: ['https://www.googleapis.com/auth/documents.readonly'],
17
+ });
18
+
19
+ var self = this;
20
+ this.async ={};
21
+ function coloredLog(message){
22
+ try{
23
+ // Only emit verbose plugin logs in non‑production environments.
24
+ // This keeps consoles clean in production while preserving rich
25
+ // traces (assistant resolution, MCP config, systemText) during
26
+ // local/dev debugging.
27
+ var env = null;
28
+ if (typeof JOE !== 'undefined' && JOE && JOE.webconfig && JOE.webconfig.env){
29
+ env = JOE.webconfig.env;
30
+ } else if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV){
31
+ env = process.env.NODE_ENV;
32
+ }
33
+ if (env && env.toLowerCase() === 'production'){
34
+ return;
35
+ }
36
+ console.log(JOE.Utils.color('[chatgpt]', 'plugin', false), message);
37
+ }catch(_e){
38
+ // If anything goes wrong determining env, default to logging so
39
+ // that development debugging is not silently broken.
40
+ try{
41
+ console.log('[chatgpt]', message);
42
+ }catch(__e){}
43
+ }
44
+ }
45
+ //xx -setup and send a test prompt to chatgpt
46
+ //xx get the api key from joe settings
47
+
48
+ //get a prompt from id
49
+ //send the prompt to chatgpt
50
+
51
+ //++get the cotnent of a file
52
+ //++send the content of a file to chatgpt
53
+
54
+ //++ structure data
55
+ //++ save the response to an ai_repsonse
56
+ //create an ai_response
57
+ //store the content
58
+ //attach to the request
59
+ //store ids sent with the request
60
+ this.default = function(data, req, res) {
61
+ try {
62
+ var payload = {
63
+ params: req.params,
64
+ data: data
65
+ };
66
+ } catch (e) {
67
+ return { errors: 'plugin error: ' + e, failedat: 'plugin' };
68
+ }
69
+ return payload;
70
+ };
71
+ function getAPIKey() {
72
+ const setting = JOE.Utils.Settings('OPENAI_API_KEY');
73
+ if (!setting) throw new Error("Missing OPENAI_API_KEY setting");
74
+ return setting;
75
+ }
76
+ function getSchemaDef(name) {
77
+ if (!name) return { full: null, summary: null };
78
+ const full = JOE.Schemas && JOE.Schemas.schema && JOE.Schemas.schema[name];
79
+ const summary = JOE.Schemas && JOE.Schemas.summary && JOE.Schemas.summary[name];
80
+ return { full, summary };
81
+ }
82
+ function buildMcpToolsFromConfig(cfg) {
83
+ if (!cfg || !cfg.mcp_enabled) {
84
+ return { tools: null, names: [] };
85
+ }
86
+ try {
87
+ const names = MCP.getToolNamesForToolset(
88
+ cfg.mcp_toolset || 'read-only',
89
+ Array.isArray(cfg.mcp_selected_tools) ? cfg.mcp_selected_tools : null
90
+ );
91
+ const defs = MCP.getToolDefinitions(names);
92
+ return { tools: defs, names: names };
93
+ } catch (e) {
94
+ console.warn('[chatgpt] buildMcpToolsFromConfig failed', e);
95
+ return { tools: null, names: [] };
96
+ }
97
+ }
98
+
99
+ /**
100
+ * callMCPTool
101
+ *
102
+ * Small, well‑scoped helper to invoke a JOE MCP tool directly in‑process,
103
+ * without going over HTTP or worrying about POST size limits.
104
+ *
105
+ * Usage:
106
+ * const result = await callMCPTool('listSchemas', {}, { req });
107
+ *
108
+ * Notes:
109
+ * - `toolName` must exist on MCP.tools.
110
+ * - `params` should be a plain JSON-serializable object.
111
+ * - `ctx` is optional and can pass `{ req }` or other context that MCP
112
+ * tools might want (for auth, user, etc.).
113
+ */
114
+ async function callMCPTool(toolName, params = {}, ctx = {}) {
115
+ if (!MCP || !MCP.tools) {
116
+ throw new Error("MCP module not initialized; cannot call MCP tool");
117
+ }
118
+ if (!toolName || typeof toolName !== 'string') {
119
+ throw new Error("Missing or invalid MCP tool name");
120
+ }
121
+ const fn = MCP.tools[toolName];
122
+ if (typeof fn !== 'function') {
123
+ throw new Error(`MCP tool "${toolName}" not found`);
124
+ }
125
+ try {
126
+ // All MCP tools accept (params, ctx) and return a JSON-serializable result.
127
+ // The Responses / tools API often returns arguments as a JSON string, so
128
+ // normalize that here before invoking the tool.
129
+ let toolParams = params;
130
+ if (typeof toolParams === 'string') {
131
+ try {
132
+ toolParams = JSON.parse(toolParams);
133
+ } catch (parseErr) {
134
+ console.error(`[chatgpt] Failed to JSON-parse tool arguments for "${toolName}"`, parseErr, toolParams);
135
+ // Fall back to passing the raw string so tools that expect it still work.
136
+ }
137
+ }
138
+ const result = await fn(toolParams || {}, ctx || {});
139
+ return result;
140
+ } catch (e) {
141
+ // Surface a clean error upstream but keep details in logs.
142
+ console.error(`[chatgpt] MCP tool "${toolName}" error:`, e);
143
+ throw new Error(`MCP tool "${toolName}" failed: ${e && e.message || 'Unknown error'}`);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * extractToolCalls
149
+ *
150
+ * Best-effort parser for tool calls from a Responses API result.
151
+ * The Responses output shape may evolve; this function looks for
152
+ * any "tool_call" typed content in response.output[*].content[*]
153
+ * and normalizes it into `{ name, arguments }` objects.
154
+ */
155
+ function extractToolCalls(response) {
156
+ var calls = [];
157
+ if (!response || !Array.isArray(response.output)) { return calls; }
158
+
159
+ response.output.forEach(function (item) {
160
+ if (!item) { return; }
161
+ // v1-style: item.type === 'tool_call'
162
+ if (item.type === 'function_call') {
163
+ calls.push({
164
+ name: item.name || item.function_name,
165
+ arguments: item.arguments || item.function_arguments || {}
166
+ });
167
+ }
168
+ // message-style: item.content is an array of parts
169
+ if (Array.isArray(item.content)) {
170
+ item.content.forEach(function (part) {
171
+ if (!part) { return; }
172
+ if (part.type === 'function_call') {
173
+ calls.push({
174
+ name: part.name || part.tool_name,
175
+ arguments: part.arguments || part.args || {}
176
+ });
177
+ }
178
+ });
179
+ }
180
+ });
181
+
182
+ return calls;
183
+ }
184
+
185
+ // Detect "request too large / token limit" style errors from the Responses API.
186
+ function isTokenLimitError(err) {
187
+ if (!err || typeof err !== 'object') return false;
188
+ if (err.status !== 429 && err.status !== 400) return false;
189
+ const msg = (err.error && err.error.message) || err.message || '';
190
+ if (!msg) return false;
191
+ const lower = String(msg).toLowerCase();
192
+ // Cover common phrasing from OpenAI for context/TPM limits.
193
+ return (
194
+ lower.includes('request too large') ||
195
+ lower.includes('too many tokens') ||
196
+ lower.includes('max tokens') ||
197
+ lower.includes('maximum context length') ||
198
+ lower.includes('tokens per min')
199
+ );
200
+ }
201
+
202
+ // Create a compact representation of a JOE object for use in slim payloads.
203
+ function slimJOEObject(item) {
204
+ if (!item || typeof item !== 'object') return item;
205
+ const name = item.name || item.title || item.label || item.email || item.slug || item._id || '';
206
+ const info = item.info || item.description || item.summary || '';
207
+ return {
208
+ _id: item._id,
209
+ itemtype: item.itemtype,
210
+ name: name,
211
+ info: info
212
+ };
213
+ }
214
+
215
+ // Given an `understandObject` result, produce a slimmed version:
216
+ // - keep `object` as-is
217
+ // - keep `flattened` for the main object (depth-limited) if present
218
+ // - replace each related entry with { field, _id, itemtype, object:{_id,itemtype,name,info} }
219
+ // - preserve `schemas`, `tags`, `statuses`, and mark `slim:true`
220
+ function slimUnderstandObjectResult(result) {
221
+ if (!result || typeof result !== 'object') return result;
222
+ const out = {
223
+ _id: result._id,
224
+ itemtype: result.itemtype,
225
+ object: result.object,
226
+ // retain main flattened view if available; this is typically much smaller
227
+ flattened: result.flattened || null,
228
+ schemas: result.schemas || {},
229
+ tags: result.tags || {},
230
+ statuses: result.statuses || {},
231
+ slim: true
232
+ };
233
+ if (Array.isArray(result.related)) {
234
+ out.related = result.related.map(function (rel) {
235
+ if (!rel) return rel;
236
+ const base = rel.object || {};
237
+ const slim = slimJOEObject(base);
238
+ return {
239
+ field: rel.field,
240
+ _id: slim && slim._id || rel._id,
241
+ itemtype: slim && slim.itemtype || rel.itemtype,
242
+ object: slim
243
+ };
244
+ });
245
+ } else {
246
+ out.related = [];
247
+ }
248
+ return out;
249
+ }
250
+
251
+ // Walk the messages array and, for any system message containing a JSON payload
252
+ // of the form { "tool": "understandObject", "result": {...} }, replace the
253
+ // result with a slimmed version to reduce token count. Returns a new array; if
254
+ // nothing was changed, returns the original array.
255
+ function shrinkUnderstandObjectMessagesForTokens(messages) {
256
+ if (!Array.isArray(messages)) return messages;
257
+ let changed = false;
258
+ const shrunk = messages.map(function (msg) {
259
+ if (!msg || msg.role !== 'system') return msg;
260
+ if (typeof msg.content !== 'string') return msg;
261
+ try {
262
+ const parsed = JSON.parse(msg.content);
263
+ if (!parsed || parsed.tool !== 'understandObject' || !parsed.result) {
264
+ return msg;
265
+ }
266
+ const slimmed = slimUnderstandObjectResult(parsed.result);
267
+ changed = true;
268
+ return {
269
+ ...msg,
270
+ content: JSON.stringify({ tool: 'understandObject', result: slimmed })
271
+ };
272
+ } catch (_e) {
273
+ return msg;
274
+ }
275
+ });
276
+ return changed ? shrunk : messages;
277
+ }
278
+
279
+ /**
280
+ * runWithTools
281
+ *
282
+ * Single orchestration function for calling the OpenAI Responses API
283
+ * with optional tools (sourced from a JOE `ai_assistant`), handling
284
+ * tool calls via MCP, and issuing a follow-up model call with the
285
+ * tool results injected.
286
+ *
287
+ * Inputs (opts):
288
+ * - openai: OpenAI client instance
289
+ * - model: model name to use (e.g. "gpt-4.1-mini", "gpt-5.1")
290
+ * - systemText: string of system / instructions text
291
+ * - messages: array of { role, content } for the conversation so far
292
+ * - assistant: JOE `ai_assistant` object (may contain `tools`)
293
+ * - req: Express request (passed into MCP tools as context)
294
+ *
295
+ * Returns:
296
+ * - { response, finalText, messages, toolCalls }
297
+ * where `finalText` is the assistant-facing text (from output_text)
298
+ * and `messages` is the possibly-extended message list including
299
+ * any synthetic `tool` messages.
300
+ */
301
+ async function runWithTools(opts) {
302
+ const openai = opts.openai;
303
+ const model = opts.model;
304
+ const systemText = opts.systemText || "";
305
+ const messages = Array.isArray(opts.messages) ? opts.messages.slice() : [];
306
+ const assistant = opts.assistant || null;
307
+ const req = opts.req;
308
+ const attachmentsMode = opts.attachments_mode || null;
309
+ const openaiFileIds = opts.openai_file_ids || null;
310
+
311
+ // Debug/trace: log the effective system instructions going into this
312
+ // Responses+tools call. This helps verify assistant + MCP instructions
313
+ // wiring across prompts, assists, autofill, and widget chat.
314
+ try{
315
+ coloredLog('runWithTools systemText:\n' + systemText);
316
+ }catch(_e){}
317
+
318
+ // Normalize tools: manual assistant.tools plus optional MCP tools
319
+ let manualTools = null;
320
+ if (assistant && assistant.tools) {
321
+ if (Array.isArray(assistant.tools)) {
322
+ manualTools = assistant.tools;
323
+ } else if (typeof assistant.tools === 'string') {
324
+ try {
325
+ const parsed = JSON.parse(assistant.tools);
326
+ if (Array.isArray(parsed)) {
327
+ manualTools = parsed;
328
+ }
329
+ } catch (e) {
330
+ console.error('[chatgpt] Failed to parse assistant.tools JSON', e);
331
+ }
332
+ }
333
+ }
334
+ // Flatten any Assistants-style function definitions
335
+ if (Array.isArray(manualTools)) {
336
+ manualTools = manualTools.map(function (t) {
337
+ if (t && t.type === 'function' && t.function && !t.name) {
338
+ const fn = t.function || {};
339
+ return {
340
+ type: 'function',
341
+ name: fn.name,
342
+ description: fn.description,
343
+ parameters: fn.parameters || {}
344
+ };
345
+ }
346
+ return t;
347
+ });
348
+ }
349
+
350
+ // Merge manual tools with MCP tools (manual wins on name collisions).
351
+ let tools = null;
352
+ const mergedByName = {};
353
+ const mcp = buildMcpToolsFromConfig(assistant || {});
354
+ const mcpTools = Array.isArray(mcp.tools) ? mcp.tools : null;
355
+
356
+ if (Array.isArray(manualTools) || Array.isArray(mcpTools)) {
357
+ tools = [];
358
+ (manualTools || []).forEach(function(t){
359
+ if (!t) { return; }
360
+ if (t.name) { mergedByName[t.name] = true; }
361
+ tools.push(t);
362
+ });
363
+ (mcpTools || []).forEach(function(t){
364
+ if (!t || !t.name) { return; }
365
+ if (mergedByName[t.name]) { return; }
366
+ tools.push(t);
367
+ });
368
+ }
369
+
370
+ // No tools configured – do a simple single Responses call.
371
+ if (!tools) {
372
+ const resp = await openai.responses.create({
373
+ model: model,
374
+ instructions: systemText,
375
+ input: messages
376
+ });
377
+ return {
378
+ response: resp,
379
+ finalText: resp.output_text || "",
380
+ messages: messages,
381
+ toolCalls: []
382
+ };
383
+ }
384
+
385
+ // Step 1: call the model with tools enabled.
386
+ let firstPayload = {
387
+ model: model,
388
+ instructions: systemText,
389
+ input: messages,
390
+ tools: tools,
391
+ tool_choice: "auto"
392
+ };
393
+ if (attachmentsMode && Array.isArray(openaiFileIds) && openaiFileIds.length){
394
+ try{
395
+ firstPayload = await attachFilesToResponsesPayload(openai, firstPayload, {
396
+ attachments_mode: attachmentsMode,
397
+ openai_file_ids: openaiFileIds
398
+ });
399
+ }catch(e){
400
+ console.warn('[chatgpt] runWithTools attachments failed; continuing without attachments', e && e.message || e);
401
+ }
402
+ }
403
+ const first = await openai.responses.create(firstPayload);
404
+
405
+ const toolCalls = extractToolCalls(first);
406
+
407
+ // If the model didn't decide to use tools, just return the first answer.
408
+ if (!toolCalls.length) {
409
+ return {
410
+ response: first,
411
+ finalText: first.output_text || "",
412
+ messages: messages,
413
+ toolCalls: []
414
+ };
415
+ }
416
+
417
+ // Step 2: execute each tool call via MCP and append tool results.
418
+ for (let i = 0; i < toolCalls.length; i++) {
419
+ const tc = toolCalls[i];
420
+ try {
421
+ const result = await callMCPTool(tc.name, tc.arguments || {}, { req });
422
+ messages.push({
423
+ // Responses API does not support a "tool" role in messages.
424
+ // We inject tool outputs as a synthetic system message so
425
+ // the model can see the results without affecting the
426
+ // user/assistant turn structure.
427
+ role: "system",
428
+ content: JSON.stringify({ tool: tc.name, result: result })
429
+ });
430
+ } catch (e) {
431
+ console.error("[chatgpt] MCP tool error in runWithTools:", e);
432
+ messages.push({
433
+ role: "system",
434
+ content: JSON.stringify({
435
+ tool: tc.name,
436
+ error: e && e.message || "Tool execution failed"
437
+ })
438
+ });
439
+ }
440
+ }
441
+
442
+ // Step 3: ask the model again with tool outputs included.
443
+ let finalMessages = messages;
444
+ let second;
445
+ try {
446
+ let secondPayload = {
447
+ model: model,
448
+ instructions: systemText,
449
+ input: finalMessages
450
+ };
451
+ if (attachmentsMode && Array.isArray(openaiFileIds) && openaiFileIds.length){
452
+ try{
453
+ secondPayload = await attachFilesToResponsesPayload(openai, secondPayload, {
454
+ attachments_mode: attachmentsMode,
455
+ openai_file_ids: openaiFileIds
456
+ });
457
+ }catch(e){
458
+ console.warn('[chatgpt] runWithTools second-call attachments failed; continuing without attachments', e && e.message || e);
459
+ }
460
+ }
461
+ second = await openai.responses.create(secondPayload);
462
+ } catch (e) {
463
+ if (isTokenLimitError(e)) {
464
+ console.warn("[chatgpt] Responses token limit hit; shrinking understandObject payloads and retrying once");
465
+ const shrunk = shrinkUnderstandObjectMessagesForTokens(finalMessages);
466
+ // If nothing was shrunk, just rethrow the original error.
467
+ if (shrunk === finalMessages) {
468
+ throw e;
469
+ }
470
+ finalMessages = shrunk;
471
+ // Retry once with the smaller payload; let any error bubble up.
472
+ let retryPayload = {
473
+ model: model,
474
+ instructions: systemText,
475
+ input: finalMessages
476
+ };
477
+ if (attachmentsMode && Array.isArray(openaiFileIds) && openaiFileIds.length){
478
+ try{
479
+ retryPayload = await attachFilesToResponsesPayload(openai, retryPayload, {
480
+ attachments_mode: attachmentsMode,
481
+ openai_file_ids: openaiFileIds
482
+ });
483
+ }catch(e2){
484
+ console.warn('[chatgpt] runWithTools retry attachments failed; continuing without attachments', e2 && e2.message || e2);
485
+ }
486
+ }
487
+ second = await openai.responses.create(retryPayload);
488
+ } else {
489
+ throw e;
490
+ }
491
+ }
492
+
493
+ return {
494
+ response: second,
495
+ finalText: second.output_text || "",
496
+ messages: finalMessages,
497
+ toolCalls: toolCalls
498
+ };
499
+ }
500
+
501
+ // function newClient(){
502
+ // var key = getAPIKey();
503
+ // var c = new OpenAI({
504
+ // apiKey: key, // This is the default and can be omitted
505
+ // });
506
+ // if(!c || !c.apiKey){
507
+ // return { errors: 'No API key provided' };
508
+ // }
509
+ // return c;
510
+ // }
511
+ function newClient() {
512
+ return new OpenAI({ apiKey: getAPIKey() });
513
+ }
514
+
515
+ // Safely call Responses API with optional temperature/top_p.
516
+ // If the model rejects these parameters, strip and retry once.
517
+ async function safeResponsesCreate(openai, payload){
518
+ try{
519
+ return await openai.responses.create(payload);
520
+ }catch(e){
521
+ try{
522
+ var msg = (e && (e.error && e.error.message) || e.message || '').toLowerCase();
523
+ var badTemp = msg.includes("unsupported parameter") && msg.includes("temperature");
524
+ var badTopP = msg.includes("unsupported parameter") && msg.includes("top_p");
525
+ var unknownTemp = msg.includes("unknown parameter") && msg.includes("temperature");
526
+ var unknownTopP = msg.includes("unknown parameter") && msg.includes("top_p");
527
+ if (badTemp || badTopP || unknownTemp || unknownTopP){
528
+ var p2 = Object.assign({}, payload);
529
+ if (p2.hasOwnProperty('temperature')) delete p2.temperature;
530
+ if (p2.hasOwnProperty('top_p')) delete p2.top_p;
531
+ console.warn('[chatgpt] Retrying without temperature/top_p due to model rejection');
532
+ return await openai.responses.create(p2);
533
+ }
534
+ }catch(_e){ /* fallthrough */ }
535
+ throw e;
536
+ }
537
+ }
538
+
539
+ // Ensure a vector store exists with the provided file_ids indexed; returns { vectorStoreId }
540
+ async function ensureVectorStoreForFiles(fileIds = []){
541
+ const openai = newClient();
542
+ // Create ephemeral store per run (could be optimized to reuse/persist later)
543
+ const vs = await openai.vectorStores.create({ name: 'JOE Prompt Run '+Date.now() });
544
+ const storeId = vs.id;
545
+ // Link files by id
546
+ for (const fid of (fileIds||[]).slice(0,10)) {
547
+ try{
548
+ await openai.vectorStores.files.create(storeId, { file_id: fid });
549
+ }catch(e){
550
+ console.warn('[chatgpt] vectorStores.files.create failed for', fid, e && e.message || e);
551
+ }
552
+ }
553
+ // Poll (best-effort) until files are processed or timeout
554
+ const timeoutMs = 8000;
555
+ const start = Date.now();
556
+ try{
557
+ while(Date.now() - start < timeoutMs){
558
+ const listed = await openai.vectorStores.files.list(storeId, { limit: 100 });
559
+ const items = (listed && listed.data) || [];
560
+ const pending = items.some(f => f.status && f.status !== 'completed');
561
+ if(!pending){ break; }
562
+ await new Promise(r => setTimeout(r, 500));
563
+ }
564
+ }catch(_e){ /* non-fatal */ }
565
+ return { vectorStoreId: storeId };
566
+ }
567
+
568
+ // ---------------- OpenAI Files helpers ----------------
569
+ /**
570
+ * attachFilesToResponsesPayload
571
+ *
572
+ * Shared helper to wire OpenAI `responses.create` payloads with file
573
+ * attachments in a consistent way for both MCP and non‑MCP paths.
574
+ *
575
+ * Modes:
576
+ * - attachments_mode === 'file_search':
577
+ * - Ensures a temporary vector store via ensureVectorStoreForFiles.
578
+ * - Adds a `file_search` tool to payload.tools (if not already present).
579
+ * - Sets payload.tool_resources.file_search.vector_store_ids.
580
+ * - Leaves payload.input as text/messages.
581
+ *
582
+ * - attachments_mode === 'direct' (default):
583
+ * - Converts the existing `input` string (if any) into an `input_text`
584
+ * part and appends up to 10 `{ type:'input_file', file_id }` parts.
585
+ * - Sets payload.input = [{ role:'user', content: parts }].
586
+ *
587
+ * This function is intentionally file‑only; it does not modify instructions
588
+ * or other payload fields.
589
+ */
590
+ async function attachFilesToResponsesPayload(openai, payload, opts){
591
+ const mode = (opts && opts.attachments_mode) || 'direct';
592
+ const fileIds = (opts && opts.openai_file_ids) || [];
593
+ if (!Array.isArray(fileIds) || !fileIds.length) {
594
+ return payload;
595
+ }
596
+ if (mode === 'file_search') {
597
+ const ensured = await ensureVectorStoreForFiles(fileIds);
598
+ payload.tools = payload.tools || [];
599
+ if (!payload.tools.find(function(t){ return t && t.type === 'file_search'; })) {
600
+ payload.tools.push({ type:'file_search' });
601
+ }
602
+ payload.tool_resources = Object.assign({}, payload.tool_resources, {
603
+ file_search: { vector_store_ids: [ ensured.vectorStoreId ] }
604
+ });
605
+ return payload;
606
+ }
607
+ // Default: direct context stuffing using input_text + input_file parts.
608
+ const parts = [];
609
+ if (typeof payload.input === 'string' && payload.input.trim().length) {
610
+ parts.push({ type:'input_text', text: String(payload.input) });
611
+ } else if (Array.isArray(payload.input)) {
612
+ // If caller already provided messages as input, preserve them by
613
+ // flattening into input_text where possible.
614
+ try{
615
+ const txt = JSON.stringify(payload.input);
616
+ if (txt && txt.length) {
617
+ parts.push({ type:'input_text', text: txt });
618
+ }
619
+ }catch(_e){}
620
+ }
621
+ fileIds.slice(0, 10).forEach(function(fid){
622
+ if (fid) {
623
+ parts.push({ type:'input_file', file_id: fid });
624
+ }
625
+ });
626
+ payload.input = [{ role:'user', content: parts }];
627
+ return payload;
628
+ }
629
+ async function uploadFileFromBuffer(buffer, filename, contentType, purpose) {
630
+ const openai = newClient();
631
+ const usePurpose = purpose || 'assistants';
632
+ const tmpDir = os.tmpdir();
633
+ const safeName = filename || ('upload_' + Date.now());
634
+ const tmpPath = path.join(tmpDir, safeName);
635
+ await fs.promises.writeFile(tmpPath, buffer);
636
+ try {
637
+ // openai.files.create accepts a readable stream
638
+ const fileStream = fs.createReadStream(tmpPath);
639
+ const created = await openai.files.create({
640
+ purpose: usePurpose,
641
+ file: fileStream
642
+ });
643
+ return { id: created.id, purpose: usePurpose };
644
+ } finally {
645
+ // best-effort cleanup
646
+ fs.promises.unlink(tmpPath).catch(()=>{});
647
+ }
648
+ }
649
+
650
+ // Expose a helper that other plugins can call in-process
651
+ this.filesUploadFromBufferHelper = async function ({ buffer, filename, contentType, purpose }) {
652
+ if (!buffer || !buffer.length) {
653
+ throw new Error('Missing buffer');
654
+ }
655
+ return await uploadFileFromBuffer(buffer, filename, contentType, purpose || 'assistants');
656
+ };
657
+
658
+ // Public endpoint to retry OpenAI upload from a URL (e.g., S3 object URL)
659
+ this.filesRetryFromUrl = async function (data, req, res) {
660
+ try {
661
+ const { default: got } = await import('got');
662
+ const url = data && (data.url || data.location);
663
+ const filename = data && data.filename || (url && url.split('/').pop()) || ('upload_' + Date.now());
664
+ const contentType = data && data.contentType || undefined;
665
+ const purpose = 'assistants';
666
+ if (!url) {
667
+ return { success: false, error: 'Missing url' };
668
+ }
669
+ const resp = await got(url, { responseType: 'buffer' });
670
+ const buffer = resp.body;
671
+ const created = await uploadFileFromBuffer(buffer, filename, contentType, purpose);
672
+ return { success: true, openai_file_id: created.id, openai_purpose: created.purpose };
673
+ } catch (e) {
674
+ return { success: false, error: e && e.message || 'Retry upload failed' };
675
+ }
676
+ };
677
+ this.testPrompt= async function(data, req, res) {
678
+ try {
679
+ var payload = {
680
+ params: req.params,
681
+ data: data
682
+ };
683
+ } catch (e) {
684
+ return { errors: 'plugin error: ' + e, failedat: 'plugin' };
685
+ }
686
+ const client = newClient();
687
+ if(client.errors){
688
+ return { errors: client.errors };
689
+ }
690
+ try {
691
+ const chatCompletion = await client.chat.completions.create({
692
+ messages: [{ role: 'user', content: 'Tell me a story about JOE: the json object editor in under 256 chars.' }],
693
+ model: 'gpt-4o',
694
+ });
695
+ coloredLog(chatCompletion);
696
+ const text = chatCompletion.choices && chatCompletion.choices[0] && chatCompletion.choices[0].message && chatCompletion.choices[0].message.content || '';
697
+ // Optionally persist as ai_response with parsed JSON when applicable
698
+ const parsed = (function(){
699
+ try {
700
+ const jt = extractJsonText(text);
701
+ return jt ? JSON.parse(jt) : null;
702
+ } catch(_e){ return null; }
703
+ })();
704
+ try {
705
+ var creator_type = null;
706
+ var creator_id = null;
707
+ try{
708
+ var u = req && req.User;
709
+ if (u && u._id){
710
+ creator_type = 'user';
711
+ creator_id = u._id;
712
+ }
713
+ }catch(_e){}
714
+ const aiResponse = {
715
+ itemtype: 'ai_response',
716
+ name: 'Test Prompt → ChatGPT',
717
+ response_type: 'testPrompt',
718
+ response: text,
719
+ response_json: parsed,
720
+ response_id: chatCompletion.id || '',
721
+ user_prompt: payload && payload.data && payload.data.prompt || 'Tell me a story about JOE: the json object editor in under 256 chars.',
722
+ model_used: 'gpt-4o',
723
+ created: (new Date()).toISOString(),
724
+ creator_type: creator_type,
725
+ creator_id: creator_id
726
+ };
727
+ JOE.Storage.save(aiResponse, 'ai_response', function(){}, { history: false, user: (req && req.User) || { name:'system' } });
728
+ } catch(_e){ /* best-effort only */ }
729
+ return {payload,chatCompletion,content:text};
730
+ } catch (error) {
731
+ if (error.status === 429) {
732
+ return { errors: 'You exceeded your current quota, please check your plan and billing details.' };
733
+ } else {
734
+ return { errors: 'plugin error: ' + error.message, failedat: 'plugin' };
735
+ }
736
+ }
737
+ }
738
+
739
+ this.sendInitialConsultTranscript= async function(data, req, res) {
740
+ coloredLog("sendInitialConsultTranscript");
741
+ //get the prompt object from the prompt id
742
+ //get the business object from the refrenced object id
743
+ //see if there is a initial_transcript_url property on that object
744
+ //if there is, get the content of the file
745
+ //send the content to chatgpt, with the template property of the prompt object
746
+ //get the response
747
+ try {
748
+ var payload = {
749
+ params: req.params,
750
+ data: data
751
+ };
752
+ } catch (e) {
753
+ return { errors: 'plugin error: ' + e, failedat: 'plugin' };
754
+ }
755
+ var businessOBJ = JOE.Data.business.find(b=>b._id == data.business);
756
+ var promptOBJ = JOE.Data.ai_prompt.find(p=>p._id == data.ai_prompt);
757
+
758
+
759
+ // See if there is an initial_transcript_url property on that object
760
+ const transcriptUrl = businessOBJ.initial_transcript_url;
761
+ if (!transcriptUrl) {
762
+ return res.jsonp({ error: 'No initial transcript URL found' });
763
+ }
764
+
765
+ //Get the content of the file from Google Docs
766
+ const transcriptContent = await getGoogleDocContent(transcriptUrl);
767
+ if (!transcriptContent || transcriptContent.error) {
768
+ return res.jsonp({ error: (transcriptContent.error && transcriptContent.error.message)||'Failed to fetch transcript content' });
769
+ }
770
+ const tokenCount = countTokens(`${promptOBJ.template}\n\n${transcriptContent}`);
771
+ payload.tokenCount = tokenCount;
772
+ coloredLog("token count: "+tokenCount);
773
+ //return res.jsonp({tokens:tokenCount,content:transcriptContent});
774
+ // Send the content to ChatGPT, with the template property of the prompt object
775
+ const client = new OpenAI({
776
+ apiKey: getAPIKey(), // This is the default and can be omitted
777
+ });
778
+
779
+ const chatResponse = await client.chat.completions.create({
780
+ messages: [{ role: 'user', content: `${promptOBJ.template}\n\n${transcriptContent}` }],
781
+ model: 'gpt-4o',
782
+ });
783
+
784
+ // Get the response
785
+ const chatContent = chatResponse.choices[0].message.content;
786
+ const responseName = `${businessOBJ.name} - ${promptOBJ.name}`;
787
+ // Save the response
788
+ await saveAIResponse({
789
+ name:responseName,
790
+ business: data.business,
791
+ ai_prompt: data.ai_prompt,
792
+ response: chatContent,
793
+ payload,
794
+ prompt_method:req.params.method
795
+ }, req && req.User);
796
+ coloredLog("response saved -"+responseName);
797
+ return {payload,
798
+ businessOBJ,
799
+ promptOBJ,
800
+ chatContent,
801
+ responseName
802
+ };
803
+
804
+ }
805
+
806
+ async function getGoogleDocContent(docUrl) {
807
+ try {
808
+ const auth = new google.auth.GoogleAuth({
809
+ scopes: ['https://www.googleapis.com/auth/documents.readonly']
810
+ });
811
+ //get google docs apikey from settings
812
+ const GOOGLE_API_KEY = JOE.Utils.Settings('GOOGLE_DOCS_API_KEY');
813
+ const docs = google.docs({ version: 'v1', auth:google_auth });
814
+ const docId = extractDocIdFromUrl(docUrl);
815
+ const doc = await docs.documents.get({ documentId: docId });
816
+
817
+ let content = doc.data.body.content.map(element => {
818
+ if (element.paragraph && element.paragraph.elements) {
819
+ return element.paragraph.elements.map(
820
+ e => e.textRun ? e.textRun.content.replace(/Euron Nicholson/g, '[EN]').replace(/\d{2}:\d{2}:\d{2}\.\d{3} --> \d{2}:\d{2}:\d{2}\.\d{3}/g, '-ts-')
821
+ : ''
822
+ ).join('');
823
+ }
824
+ return '';
825
+ }).join('\n');
826
+
827
+ // Remove timestamps and line numbers
828
+ //content = content.replace(/^\d+\n\d{2}:\d{2}:\d{2}\.\d{3} --> \d{2}:\d{2}:\d{2}\.\d{3}\n/gm, '');
829
+
830
+ return content;
831
+ } catch (error) {
832
+ console.error('Error fetching Google Doc content:', error);
833
+ return {error};
834
+ }
835
+ }
836
+ function countTokens(text, model = 'gpt-4o') {
837
+ const enc = encoding_for_model(model);
838
+ const tokens = enc.encode(text);
839
+ return tokens.length;
840
+ }
841
+ function extractDocIdFromUrl(url) {
842
+ const match = url.match(/\/d\/([a-zA-Z0-9-_]+)/);
843
+ return match ? match[1] : null;
844
+ }
845
+
846
+ async function saveAIResponse(data, user) {
847
+ try {
848
+ var creator_type = null;
849
+ var creator_id = null;
850
+ try{
851
+ if (user && user._id){
852
+ creator_type = 'user';
853
+ creator_id = user._id;
854
+ }
855
+ }catch(_e){}
856
+ const aiResponse = {
857
+ name: data.name,
858
+ itemtype: 'ai_response',
859
+ business: data.business,
860
+ ai_prompt: data.ai_prompt,
861
+ response: data.response,
862
+ payload: data.payload,
863
+ prompt_method:data.prompt_method,
864
+ created: (new Date).toISOString(),
865
+ _id:cuid(),
866
+ creator_type: creator_type,
867
+ creator_id: creator_id
868
+ // Add any other fields you want to save
869
+ };
870
+ await new Promise((resolve, reject) => {
871
+ JOE.Storage.save(aiResponse, 'ai_response', function(err, result) {
872
+ if (err) {
873
+ coloredLog('Error saving AI response: ' + err);
874
+ reject(err);
875
+ } else {
876
+ coloredLog('AI response saved successfully');
877
+ resolve(result);
878
+ }
879
+ });
880
+ });
881
+ } catch (error) {
882
+ coloredLog('Error in saveAIResponse: ' + error);
883
+ }
884
+ }
885
+
886
+ // Normalize model output that should contain JSON. Models often wrap JSON
887
+ // in markdown fences (```json ... ```), and may prepend/append prose. This
888
+ // helper strips fences and tries to isolate the first well-formed JSON
889
+ // object/array substring so JSON.parse has the best chance of succeeding.
890
+ // Handles cases where tool call logs are concatenated before the actual JSON.
891
+ function extractJsonText(raw) {
892
+ if (!raw) { return ''; }
893
+ let t = String(raw).trim();
894
+ // If there is any ```...``` fenced block, prefer its contents.
895
+ const fenceIdx = t.indexOf('```json') !== -1 ? t.indexOf('```json') : t.indexOf('```');
896
+ if (fenceIdx !== -1) {
897
+ let start = fenceIdx;
898
+ const firstNewline = t.indexOf('\n', start);
899
+ if (firstNewline !== -1) {
900
+ t = t.substring(firstNewline + 1);
901
+ } else {
902
+ t = t.substring(start + 3);
903
+ }
904
+ const lastFence = t.lastIndexOf('```');
905
+ if (lastFence !== -1) {
906
+ t = t.substring(0, lastFence);
907
+ }
908
+ t = t.trim();
909
+ }
910
+
911
+ // Handle cases where tool call logs (small JSON objects like {"tool":"..."})
912
+ // are concatenated before the actual response JSON (larger JSON object).
913
+ // Find all JSON objects and pick the largest one that's not a tool log.
914
+ const jsonCandidates = [];
915
+ const firstBrace = t.indexOf('{');
916
+ const firstBracket = t.indexOf('[');
917
+ const lastBrace = Math.max(t.lastIndexOf('}'), t.lastIndexOf(']'));
918
+
919
+ if (firstBrace === -1 && firstBracket === -1) {
920
+ return '';
921
+ }
922
+
923
+ const startPos = (firstBrace === -1) ? firstBracket :
924
+ ((firstBracket === -1) ? firstBrace : Math.min(firstBrace, firstBracket));
925
+
926
+ if (startPos === -1 || lastBrace === -1 || lastBrace <= startPos) {
927
+ return t.trim();
928
+ }
929
+
930
+ // Find all potential JSON objects
931
+ for (let i = startPos; i <= lastBrace; i++) {
932
+ if (t[i] !== '{' && t[i] !== '[') continue;
933
+
934
+ // Find matching closing brace/bracket
935
+ let depth = 0;
936
+ let inString = false;
937
+ let escape = false;
938
+ let endPos = -1;
939
+
940
+ for (let j = i; j <= lastBrace; j++) {
941
+ const char = t[j];
942
+ if (escape) {
943
+ escape = false;
944
+ continue;
945
+ }
946
+ if (char === '\\') {
947
+ escape = true;
948
+ continue;
949
+ }
950
+ if (char === '"') {
951
+ inString = !inString;
952
+ continue;
953
+ }
954
+ if (!inString) {
955
+ if (char === '{' || char === '[') {
956
+ depth++;
957
+ } else if (char === '}' || char === ']') {
958
+ depth--;
959
+ if (depth === 0) {
960
+ endPos = j;
961
+ break;
962
+ }
963
+ }
964
+ }
965
+ }
966
+
967
+ if (endPos !== -1) {
968
+ const candidate = t.substring(i, endPos + 1);
969
+ // Skip tool call logs - they match pattern {"tool":"..."}
970
+ const isToolLog = /^\s*{\s*"tool"\s*:/.test(candidate);
971
+ try {
972
+ JSON.parse(candidate);
973
+ jsonCandidates.push({
974
+ text: candidate,
975
+ length: candidate.length,
976
+ isToolLog: isToolLog
977
+ });
978
+ } catch (e) {
979
+ // Not valid JSON, skip
980
+ }
981
+ }
982
+ }
983
+
984
+ // Find the largest non-tool-log JSON object, or largest overall if all are tool logs
985
+ if (jsonCandidates.length > 0) {
986
+ // Filter out tool logs first
987
+ const nonToolLogs = jsonCandidates.filter(c => !c.isToolLog);
988
+ const candidatesToUse = nonToolLogs.length > 0 ? nonToolLogs : jsonCandidates;
989
+
990
+ // Sort by length (descending) and return the largest
991
+ candidatesToUse.sort((a, b) => b.length - a.length);
992
+ return candidatesToUse[0].text.trim();
993
+ }
994
+
995
+ // Fallback: try simple first-to-last extraction
996
+ if (t[0] !== '{' && t[0] !== '[') {
997
+ const first = startPos;
998
+ const last = lastBrace;
999
+ if (first !== -1 && last !== -1 && last > first) {
1000
+ t = t.slice(first, last + 1);
1001
+ }
1002
+ }
1003
+
1004
+ return t.trim();
1005
+ }
1006
+
1007
+ // Autofill feature (Responses API; supports assistant_id or model)
1008
+ this.autofill = async function (data, req, res) {
1009
+ const startedAt = Date.now();
1010
+ const progressToken = (data || {}).progress_token || null;
1011
+ try {
1012
+ const body = data || {};
1013
+ const objectId = body.object_id || body._id;
1014
+ const object = body.object || $J.get(objectId);
1015
+ const schemaName = body.schema || (object && object.itemtype) || body.itemtype;
1016
+ const { full: schemaFull, summary: schemaSummary } = getSchemaDef(schemaName);
1017
+ const rawFields = body.fields || body.field;
1018
+ const fields = Array.isArray(rawFields) ? rawFields : (rawFields ? [rawFields] : []);
1019
+ const userPrompt = body.prompt || '';
1020
+ const assistantId = body.assistant_id || null;
1021
+
1022
+ if (!object) {
1023
+ return { success: false, error: 'Object not found', code: 'OBJECT_NOT_FOUND' };
1024
+ }
1025
+ if (!schemaName) {
1026
+ return { success: false, error: 'Schema name not determined', code: 'SCHEMA_REQUIRED' };
1027
+ }
1028
+ if (!fields.length) {
1029
+ return { success: false, error: 'No fields specified', code: 'FIELDS_REQUIRED' };
1030
+ }
1031
+
1032
+ // Register job immediately at start
1033
+ if (progressToken && objectId) {
1034
+ const fieldId = fields.length === 1 ? fields[0] : fields.join(',');
1035
+ registerAiJobIfToken(progressToken, {
1036
+ objectId: objectId,
1037
+ fieldId: fieldId,
1038
+ status: 'starting',
1039
+ message: 'Starting field autofill...',
1040
+ progress: 0,
1041
+ total: 100
1042
+ });
1043
+ }
1044
+
1045
+ const flattened = JOE.Utils.flattenObject(object._id);
1046
+ const systemText = [
1047
+ 'You are JOE (Json Object Editor) assistant.',
1048
+ 'Task: Populate only the requested fields according to the provided schema context and JOE conventions.',
1049
+ '- Respect field types (text, number, arrays, enums, references).',
1050
+ '- Do NOT invent IDs for reference fields; only return human text for text-like fields.',
1051
+ '- If a field is an enum, choose the closest valid enum. If unsure, omit it from patch.',
1052
+ '- If a field is an array, return an array of values.',
1053
+ '- Never modify unrelated fields.',
1054
+ '- Output MUST be strict JSON with a top-level key "patch" containing only populated fields.',
1055
+ '- If you lack sufficient information, return an empty patch.'
1056
+ ].join('\\n');
1057
+
1058
+ const schemaForContext = schemaSummary || schemaFull || {};
1059
+ const userInput = JSON.stringify({
1060
+ action: 'autofill_fields',
1061
+ target_schema: schemaName,
1062
+ requested_fields: fields,
1063
+ user_prompt: userPrompt,
1064
+ object_context: flattened,
1065
+ schema_context: schemaForContext
1066
+ }, null, ' ');
1067
+
1068
+ // Update progress before OpenAI call
1069
+ if (progressToken && objectId) {
1070
+ updateAiJobIfToken(progressToken, {
1071
+ status: 'running',
1072
+ message: 'Analyzing field content...',
1073
+ progress: 10,
1074
+ total: 100
1075
+ });
1076
+ }
1077
+
1078
+ const openai = newClient();
1079
+ const model = body.model || 'gpt-4o-mini';////'gpt-5-nano';
1080
+
1081
+ // Normalize MCP options for autofill. By default, when mcp_enabled is
1082
+ // true we expose the read-only toolset, which is safe for field
1083
+ // suggestions. Callers can override toolset / selected tools.
1084
+ const mcpEnabled = !!body.mcp_enabled;
1085
+ const mcpToolset = body.mcp_toolset || 'read-only';
1086
+ const mcpSelected = Array.isArray(body.mcp_selected_tools) ? body.mcp_selected_tools : null;
1087
+ const mcpInstructionsMode = body.mcp_instructions_mode || 'auto';
1088
+
1089
+ let response;
1090
+ let mcpToolCalls = [];
1091
+ if (mcpEnabled) {
1092
+ const toolNames = MCP.getToolNamesForToolset(mcpToolset, mcpSelected);
1093
+ const toolsForModel = MCP.getToolDefinitions(toolNames);
1094
+ const mcpText = MCP.buildToolInstructions(toolNames, mcpInstructionsMode);
1095
+ const systemTextWithMcp = [systemText, mcpText || ''].join('\n').trim();
1096
+
1097
+ const messages = [{ role:'user', content:userInput }];
1098
+
1099
+ // Update progress before OpenAI API call
1100
+ if (progressToken && objectId) {
1101
+ updateAiJobIfToken(progressToken, {
1102
+ status: 'running',
1103
+ message: 'Generating field value...',
1104
+ progress: 50,
1105
+ total: 100
1106
+ });
1107
+ }
1108
+
1109
+ const runResult = await runWithTools({
1110
+ openai: openai,
1111
+ model: model,
1112
+ systemText: systemTextWithMcp,
1113
+ messages: messages,
1114
+ assistant: { tools: toolsForModel },
1115
+ req: req
1116
+ });
1117
+ response = runResult.response;
1118
+ if (runResult && Array.isArray(runResult.toolCalls)) {
1119
+ mcpToolCalls = runResult.toolCalls.map(function(tc){
1120
+ return {
1121
+ name: tc && (tc.name || tc.function_name || tc.tool_name),
1122
+ arguments: tc && tc.arguments
1123
+ };
1124
+ }).filter(function(x){ return x && x.name; });
1125
+ }
1126
+ } else {
1127
+ // For simplicity and robustness, use plain text output and instruct the
1128
+ // model to return a strict JSON object. We previously attempted the
1129
+ // Responses `json_schema` response_format, but the SDK shape can change
1130
+ // and is harder to parse reliably; text + JSON.parse is sufficient here.
1131
+ const requestBase = {
1132
+ temperature: 0.2,
1133
+ instructions: systemText,
1134
+ input: userInput
1135
+ };
1136
+ // Optional web_search tool: if the caller sets allow_web truthy, expose
1137
+ // the built-in web_search capability and let the model decide when to
1138
+ // call it.
1139
+ if (body.allow_web) {
1140
+ coloredLog("allowing web search");
1141
+ requestBase.tools = [{ type: 'web_search' }];
1142
+ requestBase.tool_choice = 'auto';
1143
+ }
1144
+
1145
+ // Update progress before OpenAI API call
1146
+ if (progressToken && objectId) {
1147
+ updateAiJobIfToken(progressToken, {
1148
+ status: 'running',
1149
+ message: 'Generating field value...',
1150
+ progress: 50,
1151
+ total: 100
1152
+ });
1153
+ }
1154
+
1155
+ if (assistantId) {
1156
+ response = await openai.responses.create({ assistant_id: assistantId, ...requestBase });
1157
+ } else {
1158
+ response = await openai.responses.create({ model, ...requestBase });
1159
+ }
1160
+ }
1161
+
1162
+ // Update progress after OpenAI response
1163
+ if (progressToken && objectId) {
1164
+ updateAiJobIfToken(progressToken, {
1165
+ status: 'running',
1166
+ message: 'Applying field value...',
1167
+ progress: 80,
1168
+ total: 100
1169
+ });
1170
+ }
1171
+
1172
+ let textOut = '';
1173
+ try { textOut = response.output_text || ''; } catch (_e) {}
1174
+ coloredLog("textOut: "+textOut);
1175
+ if (!textOut && response && Array.isArray(response.output)) {
1176
+ for (let i = 0; i < response.output.length; i++) {
1177
+ const item = response.output[i];
1178
+ if (item && item.type === 'message' && item.content && Array.isArray(item.content)) {
1179
+ const textPart = item.content.find(function (c) { return c.type === 'output_text' || c.type === 'text'; });
1180
+ if (textPart && (textPart.text || textPart.output_text)) {
1181
+ textOut = textPart.text || textPart.output_text;
1182
+ break;
1183
+ }
1184
+ }
1185
+ }
1186
+ }
1187
+
1188
+ let patch = {};
1189
+ try {
1190
+ const jsonText = extractJsonText(textOut);
1191
+ const parsed = JSON.parse(jsonText || '{}');
1192
+ patch = parsed.patch || {};
1193
+ } catch (_e) {
1194
+ console.warn('[chatgpt.autofill] Failed to parse JSON patch from model output', _e);
1195
+ }
1196
+ coloredLog("patch: "+JSON.stringify(patch));
1197
+ const filteredPatch = {};
1198
+ fields.forEach(function (f) {
1199
+ if (Object.prototype.hasOwnProperty.call(patch, f)) {
1200
+ filteredPatch[f] = patch[f];
1201
+ }
1202
+ });
1203
+ // If we got no fields back on the first attempt, retry once before
1204
+ // giving up. Avoid infinite loops by marking a retry flag.
1205
+ if (!Object.keys(filteredPatch).length && !body._retry) {
1206
+ coloredLog('[autofill] empty patch, retrying once');
1207
+ const retryBody = Object.assign({}, body, { _retry: true });
1208
+ return await self.autofill(retryBody, req, res);
1209
+ }
1210
+
1211
+ // Optional save
1212
+ let savedItem = null;
1213
+ if (body.save_history || body.save_itemtype) {
1214
+ const targetItemtype = body.save_itemtype || 'ai_response';
1215
+ if (JOE.Schemas && JOE.Schemas.schema && JOE.Schemas.schema[targetItemtype]) {
1216
+ const isAiResponse = (targetItemtype === 'ai_response');
1217
+ const toolNamesForSave = mcpEnabled ? MCP.getToolNamesForToolset(mcpToolset, mcpSelected) : [];
1218
+ const baseSave = {
1219
+ itemtype: targetItemtype,
1220
+ name: `[${schemaName}] autofill → ${fields.join(', ')}`,
1221
+ object_id: object._id,
1222
+ target_schema: schemaName,
1223
+ fields,
1224
+ prompt: userPrompt,
1225
+ patch: filteredPatch,
1226
+ model,
1227
+ raw: { response, mcp_tools_used: mcpToolCalls }
1228
+ };
1229
+ if (isAiResponse) {
1230
+ baseSave.mcp_enabled = mcpEnabled;
1231
+ baseSave.mcp_toolset = mcpToolset;
1232
+ baseSave.mcp_selected_tools = toolNamesForSave;
1233
+ baseSave.mcp_instructions_mode = mcpInstructionsMode;
1234
+ baseSave.mcp_tools_used = mcpToolCalls;
1235
+ }
1236
+ await new Promise(function (resolve) {
1237
+ JOE.Storage.save(baseSave, targetItemtype, function (_err, saved) {
1238
+ savedItem = saved || null;
1239
+ resolve();
1240
+ });
1241
+ });
1242
+ }
1243
+ }
1244
+
1245
+ const result = {
1246
+ success: true,
1247
+ patch: filteredPatch,
1248
+ model,
1249
+ usage: response && response.usage,
1250
+ saved: !!savedItem,
1251
+ saved_item: savedItem,
1252
+ elapsed_ms: Date.now() - startedAt
1253
+ };
1254
+ // Remove job with delay on success
1255
+ if (progressToken) {
1256
+ removeAiJobIfToken(progressToken, 'complete', 'Field autofill complete', 10);
1257
+ }
1258
+ return result;
1259
+ } catch (e) {
1260
+ // Remove job with delay on error
1261
+ if (progressToken) {
1262
+ removeAiJobIfToken(progressToken, 'error', 'Field autofill failed: ' + (e && e.message || 'Unknown error'), 10);
1263
+ }
1264
+ return { success: false, error: e && e.message || 'Unknown error' };
1265
+ }
1266
+ };
1267
+
1268
+ this.getResponse = function(data, req, res) {
1269
+ try {
1270
+ var prompt = data.prompt;
1271
+ if (!prompt) {
1272
+ return { error: 'No prompt provided' };
1273
+ }
1274
+
1275
+ // Simulate a response from ChatGPT
1276
+ var response = `ChatGPT response to: ${prompt}`;
1277
+ res.jsonp({ response: response });
1278
+ return { use_callback: true };
1279
+ } catch (e) {
1280
+ return { errors: 'plugin error: ' + e, failedat: 'plugin' };
1281
+ }
1282
+ };
1283
+
1284
+ this.html = function(data, req, res) {
1285
+ return JSON.stringify(self.default(data, req), '', '\t\r\n <br/>');
1286
+ };
1287
+ /* NEW AI RESPONSE API*/
1288
+
1289
+ /**
1290
+ * Register an AI job if progress_token is provided
1291
+ * @param {string} progressToken - Full job token
1292
+ * @param {object} jobData - { objectId, promptId?, promptName?, fieldId?, status?, message? }
1293
+ * @returns {boolean} Success
1294
+ */
1295
+ function registerAiJobIfToken(progressToken, jobData) {
1296
+ if (!progressToken || !jobData || !jobData.objectId) {
1297
+ return false;
1298
+ }
1299
+ try {
1300
+ const AiJobs = require('../modules/AiJobs.js');
1301
+ return AiJobs.createJob(progressToken, jobData);
1302
+ } catch (e) {
1303
+ console.error('[chatgpt] registerAiJobIfToken error:', e);
1304
+ return false;
1305
+ }
1306
+ }
1307
+
1308
+ /**
1309
+ * Update an AI job if progress_token is provided
1310
+ * @param {string} progressToken - Full job token
1311
+ * @param {object} updates - { status?, message?, progress?, total? }
1312
+ * @returns {boolean} Success
1313
+ */
1314
+ function updateAiJobIfToken(progressToken, updates) {
1315
+ if (!progressToken || !updates) {
1316
+ return false;
1317
+ }
1318
+ try {
1319
+ const AiJobs = require('../modules/AiJobs.js');
1320
+ return AiJobs.updateJob(progressToken, updates);
1321
+ } catch (e) {
1322
+ console.error('[chatgpt] updateAiJobIfToken error:', e);
1323
+ return false;
1324
+ }
1325
+ }
1326
+
1327
+ /**
1328
+ * Remove an AI job with delay if progress_token is provided
1329
+ * Automatically called with delay on complete/error (10 seconds default)
1330
+ * @param {string} progressToken - Full job token
1331
+ * @param {string} finalStatus - Optional: 'error' or 'complete' (default: 'complete')
1332
+ * @param {string} message - Optional: Final message
1333
+ * @param {number} delaySeconds - Optional: Delay before removal (default: 10)
1334
+ * @returns {number|null} Timeout ID or null
1335
+ */
1336
+ function removeAiJobIfToken(progressToken, finalStatus, message, delaySeconds) {
1337
+ if (!progressToken) {
1338
+ return null;
1339
+ }
1340
+ try {
1341
+ const AiJobs = require('../modules/AiJobs.js');
1342
+ return AiJobs.removeJobWithDelay(progressToken, finalStatus, message, delaySeconds);
1343
+ } catch (e) {
1344
+ console.error('[chatgpt] removeAiJobIfToken error:', e);
1345
+ return null;
1346
+ }
1347
+ }
1348
+
1349
+ this.executeJOEAiPrompt = async function(data, req, res) {
1350
+ const referencedObjectIds = []; // Track all objects touched during helper function
1351
+ const progressToken = data.progress_token || null;
1352
+ let objectId = null;
1353
+ try {
1354
+ const promptId = data.ai_prompt;
1355
+ // Support both payload shapes: { ai_prompt, params:{...}, ... } and flat
1356
+ const params = (data && (data.params || data)) || {};
1357
+
1358
+ if (!promptId) {
1359
+ return { error: "Missing prompt_id." };
1360
+ }
1361
+
1362
+ const prompt = await $J.get(promptId); // Use $J.get for consistency
1363
+ if (!prompt) {
1364
+ return { error: "Prompt not found." };
1365
+ }
1366
+
1367
+ // Extract objectId from params (client passes it based on content_items)
1368
+ // Look for the first value in params that looks like an object ID (CUID format)
1369
+ if (params && typeof params === 'object') {
1370
+ for (const key in params) {
1371
+ const val = params[key];
1372
+ if (typeof val === 'string' && val.length > 10 && val.includes('-')) {
1373
+ objectId = val;
1374
+ break; // Use first matching value
1375
+ }
1376
+ }
1377
+ }
1378
+
1379
+ // Register job immediately at start
1380
+ if (progressToken && objectId) {
1381
+ registerAiJobIfToken(progressToken, {
1382
+ objectId: objectId,
1383
+ promptId: promptId,
1384
+ promptName: prompt.name || promptId,
1385
+ status: 'starting',
1386
+ message: 'Starting prompt execution...',
1387
+ progress: 0,
1388
+ total: 100
1389
+ });
1390
+ }
1391
+
1392
+ // If this prompt run is associated with a JOE ai_assistant, log which
1393
+ // assistant is being used so we can debug "which agent handled this?"
1394
+ try{
1395
+ const aiAssistantId = data.ai_assistant_id || null;
1396
+ if (aiAssistantId) {
1397
+ let asst = null;
1398
+ try{
1399
+ // Prefer explicit ai_assistant schema lookup, then fallback
1400
+ // to a generic get in case datasets are flattened.
1401
+ asst = $J.get(aiAssistantId,'ai_assistant') || $J.get(aiAssistantId);
1402
+ }catch(_e){}
1403
+ const label = asst && (asst.name || asst.title || asst.info || asst._id) || aiAssistantId;
1404
+ coloredLog('[prompt] executeJOEAiPrompt using ai_assistant: '
1405
+ + label + ' [' + aiAssistantId + ']'
1406
+ + ' for prompt: ' + (prompt.name || prompt._id));
1407
+ }
1408
+ }catch(_e){}
1409
+
1410
+ let instructions = prompt.instructions || "";
1411
+ let finalInstructions=instructions;
1412
+ let finalInput='';
1413
+ // Pre-load all content_objects if content_items exist
1414
+ const contentObjects = {};
1415
+
1416
+ if (prompt.content_items && Array.isArray(prompt.content_items)) {
1417
+ for (const content of prompt.content_items) {
1418
+ if (params[content.reference]) {
1419
+ const obj = $J.get(params[content.reference]);
1420
+ if (obj) {
1421
+ contentObjects[content.itemtype] = obj;
1422
+
1423
+ // Pre-track referenced object
1424
+ if (obj._id && !referencedObjectIds.includes(obj._id)) {
1425
+ referencedObjectIds.push(obj._id);
1426
+ }
1427
+ }
1428
+ }
1429
+ }
1430
+ }
1431
+
1432
+ // Execute any helper functions if present
1433
+ if (prompt.functions) {
1434
+ const modFunc = JOE.Utils.requireFromString(prompt.functions, prompt._id);
1435
+ const helperResult = await modFunc({
1436
+ instructions,
1437
+ params,
1438
+ ai_prompt: prompt,
1439
+ content_objects: contentObjects,
1440
+ trackObject: (obj) => {
1441
+ if (obj?._id && !referencedObjectIds.includes(obj._id)) {
1442
+ referencedObjectIds.push(obj._id);
1443
+ }
1444
+ }
1445
+ });
1446
+
1447
+ if (typeof helperResult === 'object' && helperResult.error) {
1448
+ return { error: helperResult.error };
1449
+ }
1450
+
1451
+ // Assume the result is { instructions, input }
1452
+ finalInstructions = helperResult.instructions || instructions;
1453
+ finalInput = helperResult.input;
1454
+
1455
+ // Update progress after helper functions
1456
+ if (progressToken && objectId) {
1457
+ updateAiJobIfToken(progressToken, {
1458
+ status: 'running',
1459
+ message: 'Preparing prompt for OpenAI...',
1460
+ progress: 20,
1461
+ total: 100
1462
+ });
1463
+ }
1464
+ } else {
1465
+ // Update progress even if no helper functions
1466
+ if (progressToken && objectId) {
1467
+ updateAiJobIfToken(progressToken, {
1468
+ status: 'running',
1469
+ message: 'Loading prompt and content...',
1470
+ progress: 10,
1471
+ total: 100
1472
+ });
1473
+ }
1474
+ }
1475
+
1476
+ // Build a compact uploaded_files header from any referenced objects that
1477
+ // have uploader-style files with OpenAI ids. This gives the model
1478
+ // explicit metadata about which files were attached and their roles so
1479
+ // prompts (like MCP Tokenize Client) can reason about "transcript"
1480
+ // vs "summary" sources instead of guessing from content alone.
1481
+ let uploadedFilesMeta = [];
1482
+ try{
1483
+ Object.keys(contentObjects || {}).forEach(function(itemtype){
1484
+ const obj = contentObjects[itemtype];
1485
+ if (!obj || typeof obj !== 'object') { return; }
1486
+ Object.keys(obj).forEach(function(field){
1487
+ const val = obj[field];
1488
+ if (!Array.isArray(val)) { return; }
1489
+ val.forEach(function(f){
1490
+ if (f && f.openai_file_id) {
1491
+ uploadedFilesMeta.push({
1492
+ itemtype: itemtype,
1493
+ field: field,
1494
+ name: f.filename || '',
1495
+ role: f.file_role || null,
1496
+ openai_file_id: f.openai_file_id
1497
+ });
1498
+ }
1499
+ });
1500
+ });
1501
+ });
1502
+ }catch(_e){ /* best-effort only */ }
1503
+ if (uploadedFilesMeta.length) {
1504
+ try{
1505
+ const header = { uploaded_files: uploadedFilesMeta };
1506
+ if (finalInput && String(finalInput).trim().length) {
1507
+ finalInput = JSON.stringify({
1508
+ uploaded_files: uploadedFilesMeta,
1509
+ input: finalInput
1510
+ }, null, 2);
1511
+ } else {
1512
+ finalInput = JSON.stringify(header, null, 2);
1513
+ }
1514
+ }catch(_e){ /* if JSON.stringify fails, leave finalInput as-is */ }
1515
+ }
1516
+
1517
+ const openai = newClient(); // however your OpenAI client is created
1518
+
1519
+ // Normalize MCP options from the ai_prompt record.
1520
+ const mcpEnabled = !!prompt.mcp_enabled;
1521
+ const mcpToolset = prompt.mcp_toolset || 'read-only';
1522
+ const mcpSelected = Array.isArray(prompt.mcp_selected_tools) ? prompt.mcp_selected_tools : null;
1523
+ const mcpInstructionsMode = prompt.mcp_instructions_mode || 'auto';
1524
+
1525
+ // If MCP is enabled, prefer Responses+tools via runWithTools. Otherwise,
1526
+ // keep the existing single-call Responses behavior using prompt.tools.
1527
+ let response;
1528
+ let resolvedToolNames = null;
1529
+ let mcpToolCalls = [];
1530
+ if (mcpEnabled) {
1531
+ // Determine tool names from the configured toolset + overrides.
1532
+ const toolNames = MCP.getToolNamesForToolset(mcpToolset, mcpSelected);
1533
+ resolvedToolNames = toolNames;
1534
+ const toolsForModel = MCP.getToolDefinitions(toolNames);
1535
+
1536
+ // Build per-tool MCP instructions (short) and append to the existing instructions.
1537
+ const mcpText = MCP.buildToolInstructions(toolNames, mcpInstructionsMode);
1538
+ const systemText = [finalInstructions || instructions || '']
1539
+ .concat(mcpText ? ['\n', mcpText] : [])
1540
+ .join('\n')
1541
+ .trim();
1542
+
1543
+ const messages = [];
1544
+ if (finalInput && String(finalInput).trim().length) {
1545
+ messages.push({ role:'user', content:String(finalInput) });
1546
+ }
1547
+ // Ensure the Responses API always has some input when MCP is enabled.
1548
+ // For prompts that rely purely on system instructions, synthesize a
1549
+ // minimal user turn so the call remains valid.
1550
+ if (!messages.length) {
1551
+ messages.push({
1552
+ role: 'user',
1553
+ content: 'Follow the system instructions above and produce the requested output.'
1554
+ });
1555
+ }
1556
+
1557
+ // Update progress before OpenAI API call
1558
+ if (progressToken && objectId) {
1559
+ updateAiJobIfToken(progressToken, {
1560
+ status: 'running',
1561
+ message: 'Calling OpenAI API...',
1562
+ progress: 50,
1563
+ total: 100
1564
+ });
1565
+ }
1566
+
1567
+ const runResult = await runWithTools({
1568
+ openai: openai,
1569
+ model: prompt.ai_model || "gpt-4o",
1570
+ systemText: systemText,
1571
+ messages: messages,
1572
+ // Provide a synthetic assistant-style object so runWithTools can
1573
+ // normalize tools into Responses format.
1574
+ assistant: { tools: toolsForModel },
1575
+ // Pass through attachments so MCP runs see the same files as
1576
+ // non‑MCP prompts (direct or file_search modes).
1577
+ attachments_mode: prompt.attachments_mode || 'direct',
1578
+ openai_file_ids: Array.isArray(data.openai_file_ids) ? data.openai_file_ids : null,
1579
+ req: req
1580
+ });
1581
+ response = runResult.response;
1582
+ if (runResult && Array.isArray(runResult.toolCalls)) {
1583
+ mcpToolCalls = runResult.toolCalls.map(function(tc){
1584
+ return {
1585
+ name: tc && (tc.name || tc.function_name || tc.tool_name),
1586
+ arguments: tc && tc.arguments
1587
+ };
1588
+ }).filter(function(x){ return x && x.name; });
1589
+ }
1590
+ } else {
1591
+ const payloadBase = {
1592
+ model: prompt.ai_model || "gpt-4o",
1593
+ instructions: finalInstructions||instructions, // string only
1594
+ input:finalInput||'',
1595
+ tools: prompt.tools || [{ "type": "web_search" }],
1596
+ tool_choice: prompt.tool_choice || "auto",
1597
+ temperature: prompt.temperature ? parseFloat(prompt.temperature) : 0.7,
1598
+ //return_token_usage: true
1599
+ //max_tokens: prompt.max_tokens ?? 1200
1600
+ };
1601
+ coloredLog(`${payloadBase.model} and ${payloadBase.temperature}`);
1602
+ const mode = (prompt.attachments_mode || 'direct');
1603
+ let payload = payloadBase;
1604
+ if (Array.isArray(data.openai_file_ids) && data.openai_file_ids.length){
1605
+ try{
1606
+ payload = await attachFilesToResponsesPayload(openai, payloadBase, {
1607
+ attachments_mode: mode,
1608
+ openai_file_ids: data.openai_file_ids
1609
+ });
1610
+ }catch(e){
1611
+ console.warn('[chatgpt] attachFilesToResponsesPayload failed; continuing without attachments', e && e.message || e);
1612
+ }
1613
+ }
1614
+ // Update progress before OpenAI API call
1615
+ if (progressToken && objectId) {
1616
+ updateAiJobIfToken(progressToken, {
1617
+ status: 'running',
1618
+ message: 'Calling OpenAI API...',
1619
+ progress: 50,
1620
+ total: 100
1621
+ });
1622
+ }
1623
+
1624
+ response = await safeResponsesCreate(openai, payload);
1625
+ }
1626
+
1627
+
1628
+ // const payload = createResponsePayload(prompt, params, instructions, data.user_prompt);
1629
+
1630
+ // const response = await openai.chat.completions.create(payload);
1631
+
1632
+ // Update progress after OpenAI response
1633
+ if (progressToken && objectId) {
1634
+ updateAiJobIfToken(progressToken, {
1635
+ status: 'running',
1636
+ message: 'Processing response...',
1637
+ progress: 80,
1638
+ total: 100
1639
+ });
1640
+ }
1641
+
1642
+ // Extract JSON from response to strip tool logs, reasoning text, and other non-JSON content.
1643
+ // This is critical for prompts that explicitly require JSON-only output.
1644
+ const rawResponseText = response.output_text || "";
1645
+ let cleanedResponseText = rawResponseText;
1646
+ try {
1647
+ const extractedJson = extractJsonText(rawResponseText);
1648
+ if (extractedJson && extractedJson.trim().length > 0) {
1649
+ // Validate it's actually valid JSON
1650
+ JSON.parse(extractedJson);
1651
+ cleanedResponseText = extractedJson;
1652
+ }
1653
+ } catch (e) {
1654
+ // If extraction fails or JSON is invalid, fall back to raw text
1655
+ // (some prompts may not be JSON-formatted)
1656
+ console.warn('[chatgpt.executeJOEAiPrompt] Failed to extract JSON from response, using raw text:', e.message);
1657
+ }
1658
+
1659
+ // Update progress before saving
1660
+ if (progressToken && objectId) {
1661
+ updateAiJobIfToken(progressToken, {
1662
+ status: 'running',
1663
+ message: 'Saving response...',
1664
+ progress: 90,
1665
+ total: 100
1666
+ });
1667
+ }
1668
+
1669
+ const saved = await saveAiResponseRefactor({
1670
+ prompt,
1671
+ ai_response_content: cleanedResponseText,
1672
+ ai_response_raw: rawResponseText,
1673
+ user_prompt: finalInput || '',
1674
+ params,
1675
+ referenced_object_ids: referencedObjectIds,
1676
+ response_id:response.id,
1677
+ usage: response.usage || {},
1678
+ user: req && req.User,
1679
+ ai_assistant_id: data.ai_assistant_id,
1680
+ mcp_enabled: mcpEnabled,
1681
+ mcp_toolset: mcpToolset,
1682
+ mcp_selected_tools: resolvedToolNames || (Array.isArray(mcpSelected) ? mcpSelected : []),
1683
+ mcp_instructions_mode: mcpInstructionsMode,
1684
+ mcp_tools_used: mcpToolCalls
1685
+ });
1686
+ try{
1687
+ if (saved && Array.isArray(data.openai_file_ids) && data.openai_file_ids.length){
1688
+ saved.used_openai_file_ids = data.openai_file_ids.slice(0,10);
1689
+ await new Promise(function(resolve){
1690
+ JOE.Storage.save(saved,'ai_response',function(){ resolve(); },{ user: req && req.User, history:false });
1691
+ });
1692
+ }
1693
+ }catch(_e){}
1694
+
1695
+ const result = { success: true, ai_response_id: saved._id,response:cleanedResponseText,usage:response.usage };
1696
+ // Remove job with delay on success
1697
+ if (progressToken) {
1698
+ removeAiJobIfToken(progressToken, 'complete', 'Prompt execution complete', 10);
1699
+ }
1700
+ return result;
1701
+ } catch (e) {
1702
+ console.error('❌ executeJOEAiPrompt error:', e);
1703
+ // Remove job with delay on error
1704
+ if (progressToken) {
1705
+ removeAiJobIfToken(progressToken, 'error', 'Prompt execution failed: ' + (e.message || 'Unknown error'), 10);
1706
+ }
1707
+ return { error: "Failed to execute AI prompt.",message: e.message };
1708
+ }
1709
+ };
1710
+
1711
+ function createResponsePayload(prompt, params, instructions, user_prompt) {
1712
+ return {
1713
+ model: prompt.model || "gpt-4o",
1714
+ messages: [
1715
+ { role: "system", content: instructions },
1716
+ { role: "user", content: user_prompt || "" }
1717
+ ],
1718
+ tools: prompt.tools || undefined,
1719
+ tool_choice: prompt.tool_choice || "auto",
1720
+ temperature: prompt.temperature ?? 0.7,
1721
+ max_tokens: prompt.max_tokens ?? 1200
1722
+ };
1723
+ }
1724
+ async function saveAiResponseRefactor({ prompt, ai_response_content, ai_response_raw, user_prompt, params, referenced_object_ids,response_id,usage,user,ai_assistant_id, mcp_enabled, mcp_toolset, mcp_selected_tools, mcp_instructions_mode, mcp_tools_used }) {
1725
+ var response_keys = [];
1726
+ try {
1727
+ response_keys = Object.keys(JSON.parse(ai_response_content));
1728
+ }catch (e) {
1729
+ console.error('❌ Error parsing AI response content for keys:', e);
1730
+ }
1731
+ // Best-effort parse into JSON for downstream agents (Thought pipeline, etc.)
1732
+ let parsedResponse = null;
1733
+ try {
1734
+ const jt = extractJsonText(ai_response_content);
1735
+ if (jt) {
1736
+ parsedResponse = JSON.parse(jt);
1737
+ }
1738
+ } catch(_e) {
1739
+ parsedResponse = null;
1740
+ }
1741
+ var creator_type = null;
1742
+ var creator_id = null;
1743
+ try{
1744
+ if (ai_assistant_id){
1745
+ creator_type = 'ai_assistant';
1746
+ creator_id = ai_assistant_id;
1747
+ } else if (user && user._id){
1748
+ creator_type = 'user';
1749
+ creator_id = user._id;
1750
+ }
1751
+ }catch(_e){}
1752
+ const aiResponse = {
1753
+ name: `${prompt.name}`,
1754
+ itemtype: 'ai_response',
1755
+ ai_prompt: prompt._id,
1756
+ prompt_name: prompt.name,
1757
+ prompt_method:prompt.prompt_method,
1758
+ response: ai_response_content,
1759
+ response_raw: ai_response_raw || null,
1760
+ response_json: parsedResponse,
1761
+ response_keys: response_keys,
1762
+ response_id:response_id||'',
1763
+ user_prompt: user_prompt,
1764
+ params_used: params,
1765
+ usage: usage || {},
1766
+ tags: prompt.tags || [],
1767
+ model_used: prompt.ai_model || "gpt-4o",
1768
+ referenced_objects: referenced_object_ids, // new flexible array of referenced object ids
1769
+ created: (new Date).toISOString(),
1770
+ _id: cuid(),
1771
+ creator_type: creator_type,
1772
+ creator_id: creator_id
1773
+ };
1774
+ // Only attach MCP metadata when MCP was actually enabled for this run, to
1775
+ // avoid introducing nulls into history diffs.
1776
+ try{
1777
+ if (mcp_enabled) {
1778
+ aiResponse.mcp_enabled = true;
1779
+ if (mcp_toolset) { aiResponse.mcp_toolset = mcp_toolset; }
1780
+ if (Array.isArray(mcp_selected_tools) && mcp_selected_tools.length) {
1781
+ aiResponse.mcp_selected_tools = mcp_selected_tools;
1782
+ }
1783
+ if (mcp_instructions_mode) {
1784
+ aiResponse.mcp_instructions_mode = mcp_instructions_mode;
1785
+ }
1786
+ if (Array.isArray(mcp_tools_used) && mcp_tools_used.length) {
1787
+ aiResponse.mcp_tools_used = mcp_tools_used;
1788
+ }
1789
+ }
1790
+ }catch(_e){}
1791
+
1792
+ await new Promise((resolve, reject) => {
1793
+ JOE.Storage.save(aiResponse, 'ai_response', function(err, result) {
1794
+ if (err) {
1795
+ console.error('❌ Error saving AI response:', err);
1796
+ reject(err);
1797
+ } else {
1798
+ console.log('✅ AI response saved successfully');
1799
+ resolve(result);
1800
+ }
1801
+ });
1802
+ });
1803
+
1804
+ return aiResponse;
1805
+ }
1806
+
1807
+ // ---------- Widget chat endpoints (Responses API + optional assistants) ----------
1808
+ function normalizeMessages(messages) {
1809
+ if (!Array.isArray(messages)) { return []; }
1810
+ return messages.map(function (m) {
1811
+ return {
1812
+ role: m.role || 'assistant',
1813
+ content: m.content || '',
1814
+ created_at: m.created_at || m.created || new Date().toISOString()
1815
+ };
1816
+ });
1817
+ }
1818
+
1819
+ /**
1820
+ * widgetStart
1821
+ *
1822
+ * Purpose:
1823
+ * Create and persist a new `ai_widget_conversation` record for the
1824
+ * external `<joe-ai-widget>` chat component. This is a lightweight
1825
+ * conversation record that stores model, assistant, system text and
1826
+ * messages for the widget.
1827
+ *
1828
+ * Inputs (data):
1829
+ * - model (optional) override model for the widget
1830
+ * - ai_assistant_id (optional) JOE ai_assistant cuid
1831
+ * - system (optional) explicit system text
1832
+ * - source (optional) freeform source tag, defaults to "widget"
1833
+ *
1834
+ * OpenAI calls:
1835
+ * - None. This endpoint only touches storage.
1836
+ *
1837
+ * Output:
1838
+ * - { success, conversation_id, model, assistant_id }
1839
+ * where assistant_id is the OpenAI assistant_id (if present).
1840
+ */
1841
+ this.widgetStart = async function (data, req, res) {
1842
+ try {
1843
+ var body = data || {};
1844
+ // Default to a modern chat model when no assistant/model is provided.
1845
+ // If an assistant is supplied, its ai_model will override this.
1846
+ var model = body.model || "gpt-5.1";
1847
+ var assistant = body.ai_assistant_id ? $J.get(body.ai_assistant_id) : null;
1848
+ var system = body.system || (assistant && assistant.instructions) || "";
1849
+ // Prefer explicit user fields coming from the client (ai-widget-test page
1850
+ // passes _joe.User fields). Widget endpoints no longer infer from req.User
1851
+ // to keep a single, explicit source of truth.
1852
+ var user = null;
1853
+ if (body.user_id || body.user_name || body.user_color) {
1854
+ user = {
1855
+ _id: body.user_id,
1856
+ name: body.user_name,
1857
+ fullname: body.user_name,
1858
+ color: body.user_color
1859
+ };
1860
+ }
1861
+ var user_color = (body.user_color) || (user && user.color) || null;
1862
+
1863
+ var convo = {
1864
+ _id: (typeof cuid === 'function') ? cuid() : undefined,
1865
+ itemtype: "ai_widget_conversation",
1866
+ model: (assistant && assistant.ai_model) || model,
1867
+ assistant: assistant && assistant._id,
1868
+ assistant_id: assistant && assistant.assistant_id,
1869
+ assistant_color: assistant && assistant.assistant_color,
1870
+ user: user && user._id,
1871
+ user_name: user && (user.fullname || user.name),
1872
+ user_color: user_color,
1873
+ system: system,
1874
+ messages: [],
1875
+ source: body.source || "widget",
1876
+ // Optional scope for object-scoped widget chats
1877
+ scope_itemtype: body.scope_itemtype || null,
1878
+ scope_id: body.scope_id || null,
1879
+ created: new Date().toISOString(),
1880
+ joeUpdated: new Date().toISOString()
1881
+ };
1882
+ if (body.name && !convo.name) {
1883
+ convo.name = String(body.name);
1884
+ }
1885
+
1886
+ const saved = await new Promise(function (resolve, reject) {
1887
+ // Widget conversations are lightweight and do not need full history diffs.
1888
+ JOE.Storage.save(convo, "ai_widget_conversation", function (err, result) {
1889
+ if (err) return reject(err);
1890
+ resolve(result);
1891
+ }, { history: false });
1892
+ });
1893
+
1894
+ return {
1895
+ success: true,
1896
+ conversation_id: saved._id,
1897
+ model: saved.model,
1898
+ assistant_id: saved.assistant_id || null,
1899
+ assistant_color: saved.assistant_color || null,
1900
+ user_color: saved.user_color || user_color || null
1901
+ };
1902
+ } catch (e) {
1903
+ console.error("[chatgpt] widgetStart error:", e);
1904
+ return { success: false, error: e && e.message || "Unknown error" };
1905
+ }
1906
+ };
1907
+
1908
+ /**
1909
+ * widgetHistory
1910
+ *
1911
+ * Purpose:
1912
+ * Load an existing `ai_widget_conversation` and normalize its
1913
+ * messages for use by `<joe-ai-widget>` on page load or refresh.
1914
+ *
1915
+ * Inputs (data):
1916
+ * - conversation_id or _id: the widget conversation cuid
1917
+ *
1918
+ * OpenAI calls:
1919
+ * - None. Purely storage + normalization.
1920
+ *
1921
+ * Output:
1922
+ * - { success, conversation_id, model, assistant_id, messages }
1923
+ */
1924
+ this.widgetHistory = async function (data, req, res) {
1925
+ try {
1926
+ var conversation_id = data.conversation_id || data._id;
1927
+ if (!conversation_id) {
1928
+ return { success: false, error: "Missing conversation_id" };
1929
+ }
1930
+ const convo = await new Promise(function (resolve, reject) {
1931
+ JOE.Storage.load("ai_widget_conversation", { _id: conversation_id }, function (err, results) {
1932
+ if (err) return reject(err);
1933
+ resolve(results && results[0]);
1934
+ });
1935
+ });
1936
+ if (!convo) {
1937
+ return { success: false, error: "Conversation not found" };
1938
+ }
1939
+
1940
+ convo.messages = normalizeMessages(convo.messages);
1941
+ return {
1942
+ success: true,
1943
+ conversation_id: convo._id,
1944
+ model: convo.model,
1945
+ assistant_id: convo.assistant_id || null,
1946
+ assistant_color: convo.assistant_color || null,
1947
+ user_color: convo.user_color || null,
1948
+ messages: convo.messages
1949
+ };
1950
+ } catch (e) {
1951
+ console.error("[chatgpt] widgetHistory error:", e);
1952
+ return { success: false, error: e && e.message || "Unknown error" };
1953
+ }
1954
+ };
1955
+
1956
+ /**
1957
+ * widgetMessage
1958
+ *
1959
+ * Purpose:
1960
+ * Handle a single user turn for `<joe-ai-widget>`:
1961
+ * - Append the user message to the stored conversation.
1962
+ * - Call OpenAI Responses (optionally with tools from the selected
1963
+ * `ai_assistant`, via runWithTools + MCP).
1964
+ * - Append the assistant reply, persist the conversation, and return
1965
+ * the full message history plus the latest assistant message.
1966
+ *
1967
+ * Inputs (data):
1968
+ * - conversation_id or _id: cuid of the widget conversation
1969
+ * - content: user text
1970
+ * - role: user role, defaults to "user"
1971
+ * - assistant_id: optional OpenAI assistant_id (used only to
1972
+ * locate the JOE ai_assistant config)
1973
+ * - model: optional model override
1974
+ *
1975
+ * OpenAI calls:
1976
+ * - responses.create (once if no tools; twice when tools are present):
1977
+ * * First call may include tools (assistant.tools) and `tool_choice:"auto"`.
1978
+ * * Any tool calls are executed via MCP and injected as `tool` messages.
1979
+ * * Second call is plain Responses with updated messages.
1980
+ *
1981
+ * Output:
1982
+ * - { success, conversation_id, model, assistant_id, messages,
1983
+ * last_message, usage }
1984
+ */
1985
+ this.widgetMessage = async function (data, req, res) {
1986
+ try {
1987
+ var body = data || {};
1988
+ var conversation_id = body.conversation_id || body._id;
1989
+ var content = body.content;
1990
+ var role = body.role || "user";
1991
+
1992
+ if (!conversation_id || !content) {
1993
+ return { success: false, error: "Missing conversation_id or content" };
1994
+ }
1995
+
1996
+ const convo = await new Promise(function (resolve, reject) {
1997
+ JOE.Storage.load("ai_widget_conversation", { _id: conversation_id }, function (err, results) {
1998
+ if (err) return reject(err);
1999
+ resolve(results && results[0]);
2000
+ });
2001
+ });
2002
+ if (!convo) {
2003
+ return { success: false, error: "Conversation not found" };
2004
+ }
2005
+
2006
+ // Best-effort: if this is an object-scoped conversation and we have
2007
+ // not yet attached any files, walk the scoped object for uploader
2008
+ // style files that have OpenAI ids and cache them on the convo.
2009
+ try{
2010
+ if ((!convo.attached_openai_file_ids || !convo.attached_openai_file_ids.length) &&
2011
+ convo.scope_itemtype && convo.scope_id) {
2012
+ var scopedObj = null;
2013
+ try{
2014
+ scopedObj = $J.get(convo.scope_id, convo.scope_itemtype) || $J.get(convo.scope_id);
2015
+ }catch(_e){}
2016
+ if (scopedObj && typeof scopedObj === 'object') {
2017
+ var ids = [];
2018
+ var meta = [];
2019
+ Object.keys(scopedObj).forEach(function(field){
2020
+ var val = scopedObj[field];
2021
+ if (!Array.isArray(val)) { return; }
2022
+ val.forEach(function(f){
2023
+ if (f && f.openai_file_id) {
2024
+ ids.push(f.openai_file_id);
2025
+ meta.push({
2026
+ itemtype: scopedObj.itemtype || convo.scope_itemtype,
2027
+ field: field,
2028
+ name: f.filename || '',
2029
+ role: f.file_role || null,
2030
+ openai_file_id: f.openai_file_id
2031
+ });
2032
+ }
2033
+ });
2034
+ });
2035
+ if (ids.length) {
2036
+ convo.attached_openai_file_ids = ids;
2037
+ convo.attached_files_meta = meta;
2038
+ }
2039
+ }
2040
+ }
2041
+ }catch(_e){ /* non-fatal */ }
2042
+
2043
+ convo.messages = normalizeMessages(convo.messages);
2044
+
2045
+ // On the very first turn of an object-scoped widget conversation,
2046
+ // pre-load a slimmed understandObject snapshot so the assistant
2047
+ // immediately knows which record "this client/task/..." refers to
2048
+ // without having to remember to call MCP. We keep this snapshot
2049
+ // concise via slimUnderstandObjectResult and only inject it once.
2050
+ try{
2051
+ var isObjectChat = (convo.source === 'object_chat') && convo.scope_id;
2052
+ var hasMessages = Array.isArray(convo.messages) && convo.messages.length > 0;
2053
+ if (isObjectChat && !hasMessages){
2054
+ const uo = await callMCPTool('understandObject', {
2055
+ _id: convo.scope_id,
2056
+ itemtype: convo.scope_itemtype || undefined,
2057
+ depth: 1,
2058
+ slim: true
2059
+ }, { req });
2060
+ const slimmed = slimUnderstandObjectResult(uo);
2061
+ if (slimmed) {
2062
+ convo.messages = convo.messages || [];
2063
+ convo.messages.push({
2064
+ role: 'system',
2065
+ content: JSON.stringify({
2066
+ tool: 'understandObject',
2067
+ scope_object: slimmed
2068
+ })
2069
+ });
2070
+ }
2071
+ }
2072
+ }catch(_e){
2073
+ console.warn('[chatgpt] widgetMessage understandObject preload failed', _e && _e.message || _e);
2074
+ }
2075
+
2076
+ const nowIso = new Date().toISOString();
2077
+
2078
+ // Append user message
2079
+ const userMsg = { role: role, content: content, created_at: nowIso };
2080
+ convo.messages.push(userMsg);
2081
+
2082
+ // Backfill user metadata (id/name/color) on older conversations that
2083
+ // were created before we started storing these fields. Prefer explicit
2084
+ // body fields only; we no longer infer from req.User so that widget
2085
+ // calls always have a single, explicit user source.
2086
+ var u = null;
2087
+ if (body.user_id || body.user_name || body.user_color) {
2088
+ u = {
2089
+ _id: body.user_id,
2090
+ name: body.user_name,
2091
+ fullname: body.user_name,
2092
+ color: body.user_color
2093
+ };
2094
+ }
2095
+ if (u) {
2096
+ if (!convo.user && u._id) {
2097
+ convo.user = u._id;
2098
+ }
2099
+ if (!convo.user_name && (u.fullname || u.name)) {
2100
+ convo.user_name = u.fullname || u.name;
2101
+ }
2102
+ if (!convo.user_color && u.color) {
2103
+ convo.user_color = u.color;
2104
+ }
2105
+ }
2106
+
2107
+ // Resolve the JOE ai_assistant driving this conversation. We support
2108
+ // both the modern flow (ai_assistant_id / convo.assistant, which are
2109
+ // JOE cuid references) and the legacy OpenAI Assistants flow
2110
+ // (assistant_id / convo.assistant_id, which are OpenAI ids).
2111
+ var assistantObj = null;
2112
+ var joeAssistantId = body.ai_assistant_id || convo.assistant || null; // JOE cuid
2113
+ if (joeAssistantId) {
2114
+ try{
2115
+ // Prefer a direct lookup via the ai_assistant schema, but fall
2116
+ // back to scanning the in-memory dataset if needed. In some
2117
+ // server contexts $J.get may not be wired for ai_assistant yet,
2118
+ // while JOE.Data.ai_assistant is available.
2119
+ assistantObj = $J.get(joeAssistantId,'ai_assistant') || $J.get(joeAssistantId) || null;
2120
+ }catch(_e){}
2121
+ if (!assistantObj && JOE && JOE.Data && Array.isArray(JOE.Data.ai_assistant)) {
2122
+ assistantObj = JOE.Data.ai_assistant.find(function(a){
2123
+ return a && (a._id === joeAssistantId);
2124
+ }) || null;
2125
+ }
2126
+ }
2127
+ const assistantId = body.assistant_id || convo.assistant_id || (assistantObj && assistantObj.assistant_id) || null;
2128
+ // Legacy fallback: if we only have an OpenAI assistant_id, try to
2129
+ // locate the JOE ai_assistant by that id.
2130
+ if (!assistantObj && assistantId && JOE && JOE.Data && Array.isArray(JOE.Data.ai_assistant)) {
2131
+ assistantObj = JOE.Data.ai_assistant.find(function (a) {
2132
+ return a && a.assistant_id === assistantId;
2133
+ }) || null;
2134
+ }
2135
+
2136
+ // Log which ai_assistant (if any) is being used for this widget
2137
+ // conversation so we can easily confirm the active agent when
2138
+ // debugging object chat vs AI Hub behavior.
2139
+ try{
2140
+ coloredLog('[widget] assistant resolution: '
2141
+ + 'convo.assistant=' + String(convo.assistant || '')
2142
+ + ' convo.assistant_id=' + String(convo.assistant_id || '')
2143
+ + ' body.ai_assistant_id=' + String(body.ai_assistant_id || '')
2144
+ + ' body.assistant_id=' + String(body.assistant_id || '')
2145
+ + ' resolvedJoe=' + String(assistantObj && assistantObj._id || ''));
2146
+ if (assistantObj) {
2147
+ var asstLabel = assistantObj.name || assistantObj.title || assistantObj.info || assistantObj._id || assistantId;
2148
+ coloredLog('[widget] widgetMessage using ai_assistant: '
2149
+ + asstLabel + ' [' + (assistantObj._id || assistantId || '') + ']'
2150
+ + ' source=' + String(convo.source || 'widget')
2151
+ + ' convo=' + String(convo._id || ''));
2152
+ } else if (assistantId) {
2153
+ coloredLog('[widget] widgetMessage assistant_id (no JOE ai_assistant found): '
2154
+ + assistantId
2155
+ + ' source=' + String(convo.source || 'widget')
2156
+ + ' convo=' + String(convo._id || ''));
2157
+ }
2158
+ }catch(_e){}
2159
+
2160
+ const openai = newClient();
2161
+ const model = (assistantObj && assistantObj.ai_model) || convo.model || body.model || "gpt-5.1";
2162
+
2163
+ // Prefer explicit system text on the conversation, then assistant instructions.
2164
+ const baseSystemText = (convo.system && String(convo.system)) ||
2165
+ (assistantObj && assistantObj.instructions) ||
2166
+ "";
2167
+
2168
+ // When this conversation was launched from an object ("Start Chat"
2169
+ // on a record), include a small scope hint so the assistant knows
2170
+ // which object id/itemtype to use with MCP tools like
2171
+ // understandObject/search. We keep this concise to avoid
2172
+ // unnecessary tokens but still make the scope unambiguous.
2173
+ let systemText = baseSystemText;
2174
+ try{
2175
+ if (convo.source === 'object_chat' && convo.scope_id) {
2176
+ const scopeLine = '\n\n---\nJOE scope_object:\n'
2177
+ + '- itemtype: ' + String(convo.scope_itemtype || 'unknown') + '\n'
2178
+ + '- _id: ' + String(convo.scope_id) + '\n'
2179
+ + 'When you need this object\'s details, call the MCP tool "understandObject" '
2180
+ + 'with these identifiers, or search for related records using the MCP search tools.\n';
2181
+ systemText = (baseSystemText || '') + scopeLine;
2182
+ }
2183
+ }catch(_e){ /* non-fatal */ }
2184
+
2185
+ // Append MCP tool instructions for assistants that have MCP enabled,
2186
+ // using the same helper as other MCP-aware surfaces.
2187
+ try{
2188
+ if (assistantObj && assistantObj.mcp_enabled) {
2189
+ const mcpCfg = {
2190
+ mcp_enabled: assistantObj.mcp_enabled,
2191
+ mcp_toolset: assistantObj.mcp_toolset,
2192
+ mcp_selected_tools: assistantObj.mcp_selected_tools,
2193
+ mcp_instructions_mode: assistantObj.mcp_instructions_mode || 'auto'
2194
+ };
2195
+ const mcp = buildMcpToolsFromConfig(mcpCfg);
2196
+ coloredLog('[widget] MCP config for assistant '
2197
+ + String(assistantObj._id || '') + ': enabled=' + String(mcpCfg.mcp_enabled)
2198
+ + ' toolset=' + String(mcpCfg.mcp_toolset || '')
2199
+ + ' names=' + JSON.stringify(mcp.names || []));
2200
+ if (mcp.names && mcp.names.length) {
2201
+ const txt = MCP.buildToolInstructions(mcp.names, mcpCfg.mcp_instructions_mode || 'auto');
2202
+ if (txt) {
2203
+ coloredLog('[widget] appending MCP instructions block to systemText');
2204
+ systemText = (systemText || '') + '\n\n' + txt;
2205
+ }
2206
+ }
2207
+ } else {
2208
+ coloredLog('[widget] MCP disabled for this assistant or assistant missing; no MCP block appended.');
2209
+ }
2210
+ }catch(_e){ /* non-fatal */ }
2211
+
2212
+ // Build the messages array for the model. We deliberately separate
2213
+ // the stored `convo.messages` from the model-facing payload so we
2214
+ // can annotate the latest user turn with uploaded_files metadata
2215
+ // without altering the persisted history.
2216
+ const messagesForModel = convo.messages.map(function (m) {
2217
+ return { role: m.role, content: m.content };
2218
+ });
2219
+ // If we have attached file metadata, wrap the latest user turn in a
2220
+ // small JSON envelope so the model can see which files exist and how
2221
+ // they are labeled (role, name, origin field) while still receiving
2222
+ // the raw user input as `input`.
2223
+ try{
2224
+ if (convo.attached_files_meta && convo.attached_files_meta.length && messagesForModel.length) {
2225
+ var lastMsg = messagesForModel[messagesForModel.length - 1];
2226
+ if (lastMsg && lastMsg.role === role && typeof lastMsg.content === 'string') {
2227
+ lastMsg.content = JSON.stringify({
2228
+ uploaded_files: convo.attached_files_meta,
2229
+ input: lastMsg.content
2230
+ }, null, 2);
2231
+ }
2232
+ }
2233
+ }catch(_e){ /* non-fatal */ }
2234
+
2235
+ // Collect OpenAI file ids from scoped object attachments and any
2236
+ // assistant-level files so they are available to the model via the
2237
+ // shared attachFilesToResponsesPayload helper inside runWithTools.
2238
+ var openaiFileIds = [];
2239
+ if (Array.isArray(convo.attached_openai_file_ids) && convo.attached_openai_file_ids.length){
2240
+ openaiFileIds = openaiFileIds.concat(convo.attached_openai_file_ids);
2241
+ }
2242
+ try{
2243
+ if (assistantObj && Array.isArray(assistantObj.assistant_files)) {
2244
+ assistantObj.assistant_files.forEach(function(f){
2245
+ if (f && f.openai_file_id) {
2246
+ openaiFileIds.push(f.openai_file_id);
2247
+ }
2248
+ });
2249
+ }
2250
+ }catch(_e){}
2251
+
2252
+ // Use runWithTools so that, when an assistant has tools configured,
2253
+ // we let the model call those tools via MCP / function tools before
2254
+ // generating a final response. Attach any discovered OpenAI files
2255
+ // so the model can read from them as needed.
2256
+ const runResult = await runWithTools({
2257
+ openai: openai,
2258
+ model: model,
2259
+ systemText: systemText,
2260
+ messages: messagesForModel,
2261
+ assistant: assistantObj,
2262
+ attachments_mode: (body.attachments_mode || 'direct'),
2263
+ openai_file_ids: openaiFileIds.length ? openaiFileIds : null,
2264
+ req: req
2265
+ });
2266
+
2267
+ // If tools were called this turn, inject a small meta message so the
2268
+ // widget clearly shows which functions ran before the assistant reply.
2269
+ if (runResult.toolCalls && runResult.toolCalls.length) {
2270
+ const names = runResult.toolCalls.map(function (tc) { return tc && tc.name; })
2271
+ .filter(Boolean)
2272
+ .join(', ');
2273
+ convo.messages.push({
2274
+ role: "assistant",
2275
+ meta: "tools_used",
2276
+ content: "[Tools used this turn: " + names + "]",
2277
+ created_at: nowIso
2278
+ });
2279
+ }
2280
+
2281
+ const assistantText = runResult.finalText || "";
2282
+ const assistantMsg = {
2283
+ role: "assistant",
2284
+ content: assistantText,
2285
+ created_at: new Date().toISOString()
2286
+ };
2287
+ convo.messages.push(assistantMsg);
2288
+ convo.last_message_at = assistantMsg.created_at;
2289
+ convo.joeUpdated = assistantMsg.created_at;
2290
+
2291
+ await new Promise(function (resolve, reject) {
2292
+ // Skip history for widget conversations to avoid heavy diffs / craydent.equals issues.
2293
+ JOE.Storage.save(convo, "ai_widget_conversation", function (err, saved) {
2294
+ if (err) return reject(err);
2295
+ resolve(saved);
2296
+ }, { history: false });
2297
+ });
2298
+
2299
+ return {
2300
+ success: true,
2301
+ conversation_id: convo._id,
2302
+ model: model,
2303
+ assistant_id: assistantId,
2304
+ assistant_color: (assistantObj && assistantObj.assistant_color) || convo.assistant_color || null,
2305
+ user_color: convo.user_color || ((u && u.color) || null),
2306
+ messages: convo.messages,
2307
+ last_message: assistantMsg,
2308
+ // Usage comes from the underlying Responses call inside runWithTools.
2309
+ usage: (runResult.response && runResult.response.usage) || {}
2310
+ };
2311
+ } catch (e) {
2312
+ console.error("[chatgpt] widgetMessage error:", e);
2313
+ return { success: false, error: e && e.message || "Unknown error" };
2314
+ }
2315
+ };
2316
+
2317
+ // Mark async plugin methods so Server.pluginHandling will await them.
2318
+ this.async = {
2319
+ executeJOEAiPrompt: this.executeJOEAiPrompt,
2320
+ testPrompt: this.testPrompt,
2321
+ sendInitialConsultTranscript: this.sendInitialConsultTranscript,
2322
+ widgetStart: this.widgetStart,
2323
+ widgetHistory: this.widgetHistory,
2324
+ widgetMessage: this.widgetMessage,
2325
+ autofill: this.autofill,
2326
+ filesRetryFromUrl: this.filesRetryFromUrl
2327
+ };
2328
+ this.protected = [,'testPrompt'];
2329
+ return self;
2330
+ }
2331
+
2332
+ module.exports = new ChatGPT();