@xuda.io/ai_module 1.1.5651 → 1.1.5653
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.mjs +295 -47
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -231,6 +231,62 @@ const resolve_ai_model = function (m) {
|
|
|
231
231
|
return _conf?.ai_models?.[code]?.model || _conf?.ai_models?.[m]?.model || m;
|
|
232
232
|
};
|
|
233
233
|
|
|
234
|
+
// Every text model in the catalog is a gpt-5-family REASONING model, and the API
|
|
235
|
+
// default effort is 'medium'. Measured on dev 2026-08-11: gpt-5-nano asked to write
|
|
236
|
+
// a three line email burned 960-1600 hidden reasoning tokens and took 10-16s, while
|
|
237
|
+
// the same call at effort 'minimal' took 1.4-2.7s with 0 reasoning tokens and the
|
|
238
|
+
// same answer. The cheapest model was the SLOWEST thing we ran, purely because
|
|
239
|
+
// nobody set an effort. So every internal call now names one.
|
|
240
|
+
//
|
|
241
|
+
// 'minimal' is the right default for what submit_chat_gpt_prompt is actually used
|
|
242
|
+
// for (titles, categories, field assist, short classifications). A caller that
|
|
243
|
+
// genuinely needs the model to think passes effort: 'low' | 'medium' | 'high'.
|
|
244
|
+
//
|
|
245
|
+
// Non-reasoning ids (gpt-4o-mini and friends, still named by a few modules) reject
|
|
246
|
+
// the parameter outright, so only send it to a model that understands it.
|
|
247
|
+
const INTERNAL_AI_EFFORT = () => _conf?.internal_ai_effort || 'minimal';
|
|
248
|
+
|
|
249
|
+
const _is_reasoning_model = function (real_model) {
|
|
250
|
+
return /^(gpt-5|o[1-9])/.test(String(real_model || ''));
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// Responses-API shape: { reasoning: { effort } }. Returns {} when the model or the
|
|
254
|
+
// caller opted out, so it can always be spread into the request.
|
|
255
|
+
const reasoning_opt = function (real_model, effort) {
|
|
256
|
+
const e = effort === undefined ? INTERNAL_AI_EFFORT() : effort;
|
|
257
|
+
if (!e || e === 'default' || !_is_reasoning_model(real_model)) return {};
|
|
258
|
+
return { reasoning: { effort: e } };
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// Request timeout, sized off the effort tier.
|
|
262
|
+
//
|
|
263
|
+
// Measured on dev 2026-08-11: roughly 13% of calls to api.openai.com go silent for
|
|
264
|
+
// 19.5-20s AFTER the edge has ACKed our request, on a 401 to /v1/models as readily
|
|
265
|
+
// as on a real completion, and from the laptop as readily as from dev. tcpdump shows
|
|
266
|
+
// no retransmission in either direction, so it is upstream at OpenAI and nothing here
|
|
267
|
+
// fixes it. See the cf-ray IDs in the change log.
|
|
268
|
+
//
|
|
269
|
+
// What we CAN stop doing is waiting it out. The SDK had no timeout set, so its 10
|
|
270
|
+
// minute default applied and every stall cost the full 20s. A stalled request never
|
|
271
|
+
// recovers early and a fresh one answers in ~200ms, so the cure is to give up and
|
|
272
|
+
// re-ask. The stall lands before generation starts, so the abandoned attempt bills
|
|
273
|
+
// nothing and there is no reason to hedge (leave the first running) instead.
|
|
274
|
+
//
|
|
275
|
+
// Timeouts are ~6x the observed normal for the tier, so only a genuinely stuck call
|
|
276
|
+
// trips them. The SDK's own maxRetries (2) does the re-asking and treats a timeout as
|
|
277
|
+
// retryable, which puts 'minimal' at ~9s instead of ~21s for a stall.
|
|
278
|
+
const AI_EFFORT_TIMEOUT_MS = { minimal: 8000, low: 15000, medium: 60000, high: 120000 };
|
|
279
|
+
const AI_TIMEOUT_DEFAULT_MS = 60000;
|
|
280
|
+
|
|
281
|
+
const ai_timeout_ms = function (effort, real_model, tools) {
|
|
282
|
+
// A hosted tool (web_search and friends) runs its own loop inside the one request
|
|
283
|
+
// and is legitimately slow, so those never get the short tier.
|
|
284
|
+
if (Array.isArray(tools) && tools.length) return _conf.ai_tools_timeout_ms || 180000;
|
|
285
|
+
if (!_is_reasoning_model(real_model)) return _conf.ai_timeout_ms?.default || AI_TIMEOUT_DEFAULT_MS;
|
|
286
|
+
const e = effort === undefined ? INTERNAL_AI_EFFORT() : effort;
|
|
287
|
+
return _conf.ai_timeout_ms?.[e] || AI_EFFORT_TIMEOUT_MS[e] || _conf.ai_timeout_ms?.default || AI_TIMEOUT_DEFAULT_MS;
|
|
288
|
+
};
|
|
289
|
+
|
|
234
290
|
// A catalog code a user may pick for Codex generation: an OpenAI text model
|
|
235
291
|
// flagged codex:true in ai_models. Guards generate_site_draft's model param so a
|
|
236
292
|
// non-codex pick (image/voice/unknown) falls back to the default rather than
|
|
@@ -370,6 +426,53 @@ const account_msa = await import(`${module_path}/account_module/index_msa.mjs`);
|
|
|
370
426
|
const drive_msa = await import(`${module_path}/drive_module/index_msa.mjs`);
|
|
371
427
|
const misc_msa = await import(`${module_path}/misc_module/index_msa.mjs`);
|
|
372
428
|
|
|
429
|
+
// Did the user press Stop on this run?
|
|
430
|
+
//
|
|
431
|
+
// abort_job stamps `abort: true` on the job doc and nothing else. The doc only
|
|
432
|
+
// flips to stat 4 when the worker calls update_job and that call sees the flag,
|
|
433
|
+
// and a chat run never calls update_job: it reports progress over the websocket
|
|
434
|
+
// (emitToDashboard), not through the job. So every poll here used to watch a
|
|
435
|
+
// stat that could not change, and Stop did nothing but leave the response
|
|
436
|
+
// streaming. Read the flag itself, and keep the stat + "job vanished" cases so
|
|
437
|
+
// non-chat runs (which do go through update_job) behave exactly as before.
|
|
438
|
+
const is_job_aborted = async function (job_id) {
|
|
439
|
+
if (!job_id) return false;
|
|
440
|
+
try {
|
|
441
|
+
const job_info = await jobs_ms.get_job_info({ job_id });
|
|
442
|
+
return job_info.code < 0 || job_info?.data?.abort === true || job_info?.data?.stat === 4;
|
|
443
|
+
} catch (err) {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
// Watch for Stop for as long as a response stream is open, and cut the stream when it comes.
|
|
449
|
+
//
|
|
450
|
+
// The per-chunk check inside the read loop only runs when a chunk actually arrives, and a
|
|
451
|
+
// reasoning model can sit silent for twenty seconds before its first token. That silence is
|
|
452
|
+
// exactly when the user reaches for Stop, and the loop is parked on `await` the whole time, so
|
|
453
|
+
// the run stayed unstoppable until it started talking (measured: 26s from Stop to stop).
|
|
454
|
+
// Destroying the reader makes the parked for-await throw, which lands in the same abort path
|
|
455
|
+
// the per-chunk check uses. Returns the function that cancels the watch.
|
|
456
|
+
const watch_job_abort = function (job_id, output_stream) {
|
|
457
|
+
if (!job_id || !output_stream) return () => {};
|
|
458
|
+
let checking = false;
|
|
459
|
+
const timer = setInterval(async () => {
|
|
460
|
+
if (checking) return;
|
|
461
|
+
checking = true;
|
|
462
|
+
try {
|
|
463
|
+
if (await is_job_aborted(job_id)) {
|
|
464
|
+
clearInterval(timer);
|
|
465
|
+
try {
|
|
466
|
+
output_stream.destroy?.(new Error('aborted'));
|
|
467
|
+
} catch (err) {}
|
|
468
|
+
}
|
|
469
|
+
} finally {
|
|
470
|
+
checking = false;
|
|
471
|
+
}
|
|
472
|
+
}, 500);
|
|
473
|
+
return () => clearInterval(timer);
|
|
474
|
+
};
|
|
475
|
+
|
|
373
476
|
var open_ai_status = {};
|
|
374
477
|
const report_ai_status = function (model, err) {
|
|
375
478
|
open_ai_status[model] = { stat: err ? 'error' : 'ok', date: new Date(), err };
|
|
@@ -1465,6 +1568,41 @@ const chat_finished_summary = function (text) {
|
|
|
1465
1568
|
return plain.length > 160 ? `${plain.slice(0, 157)}...` : plain;
|
|
1466
1569
|
};
|
|
1467
1570
|
|
|
1571
|
+
// The alert should look like the chat it came from, so it carries that chat's own
|
|
1572
|
+
// picture: the agent's image, the generated chat thumbnail, or the contact's avatar,
|
|
1573
|
+
// the same one the chat card shows. Returns undefined when the chat has no picture yet,
|
|
1574
|
+
// and notification_module then falls back to the app icon as before. Never throws: an
|
|
1575
|
+
// alert without a picture is still an alert.
|
|
1576
|
+
const chat_finished_image = async function (uid, conversation_doc) {
|
|
1577
|
+
try {
|
|
1578
|
+
const studio_meta = conversation_doc?.studio_meta;
|
|
1579
|
+
const agent_image = studio_meta?.agent_image?.[0]?.file_url || studio_meta?.agent_marketplace_image?.[0]?.file_url;
|
|
1580
|
+
if (agent_image) return agent_image;
|
|
1581
|
+
|
|
1582
|
+
// Same field the chat card reads, so the alert and the card agree.
|
|
1583
|
+
const chat_image = conversation_doc?.chat_image?.[0]?.file_url;
|
|
1584
|
+
if (chat_image) return chat_image;
|
|
1585
|
+
|
|
1586
|
+
const reference_type = conversation_doc?.reference_type;
|
|
1587
|
+
const reference_id = conversation_doc?.reference_id;
|
|
1588
|
+
if (!reference_id) return undefined;
|
|
1589
|
+
|
|
1590
|
+
if (reference_type === 'contacts') {
|
|
1591
|
+
const contact = await get_contact_info(uid, null, reference_id);
|
|
1592
|
+
return contact?.profile_picture || contact?.profile_avatar || undefined;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
if (reference_type === 'ai_agents') {
|
|
1596
|
+
const account_profile_info = await get_active_account_profile_info(uid);
|
|
1597
|
+
const agent_doc = await load_ai_agent_doc(account_profile_info?.app_id, reference_id);
|
|
1598
|
+
return agent_doc?.studio_meta?.agent_image?.[0]?.file_url || agent_doc?.agent_image?.[0]?.file_url || undefined;
|
|
1599
|
+
}
|
|
1600
|
+
} catch (err) {
|
|
1601
|
+
console.error(`[chat_finished_image] ${err?.message || err}`);
|
|
1602
|
+
}
|
|
1603
|
+
return undefined;
|
|
1604
|
+
};
|
|
1605
|
+
|
|
1468
1606
|
// Some flows can reach a second terminal event in one turn (a stream that completed and
|
|
1469
1607
|
// then failed while its result was being persisted), and each one closes the stream. The
|
|
1470
1608
|
// user only wants to be told once per chat, so keep the last alert per conversation and
|
|
@@ -1491,6 +1629,7 @@ const notify_chat_finished = async function ({ uid, conversation_id, conversatio
|
|
|
1491
1629
|
if (!presence || presence.code < 0 || presence.data !== false) return;
|
|
1492
1630
|
|
|
1493
1631
|
const title = String(conversation_doc?.title || '').trim();
|
|
1632
|
+
const image = await chat_finished_image(uid, conversation_doc);
|
|
1494
1633
|
|
|
1495
1634
|
// Stamped only now that an alert is really going out. Stamping every finished run
|
|
1496
1635
|
// would let a run nobody needed to hear about (the user was watching it) silence the
|
|
@@ -1507,6 +1646,8 @@ const notify_chat_finished = async function ({ uid, conversation_id, conversatio
|
|
|
1507
1646
|
uid_arr: [uid],
|
|
1508
1647
|
subject: title ? `Ready: ${title}` : 'Your chat is ready',
|
|
1509
1648
|
body: chat_finished_summary(text),
|
|
1649
|
+
// The chat's own picture, on the push and on the toast that stands in for it.
|
|
1650
|
+
...(image ? { icon: image } : {}),
|
|
1510
1651
|
delivery_method: ['push'],
|
|
1511
1652
|
display_type: 'info',
|
|
1512
1653
|
ref: conversation_id,
|
|
@@ -4763,7 +4904,7 @@ export const delete_prompt_attachment = async function (req, job_id, headers, fi
|
|
|
4763
4904
|
};
|
|
4764
4905
|
|
|
4765
4906
|
export const submit_chat_gpt_prompt = async function (req) {
|
|
4766
|
-
const { model = _conf.default_ai_model, prompt = '', response_format, uid, metadata = {}, account_profile_info, tools = [], conversation_id, response_id } = req; //'gpt-5-mini
|
|
4907
|
+
const { model = _conf.default_ai_model, prompt = '', response_format, uid, metadata = {}, account_profile_info, tools = [], conversation_id, response_id, effort } = req; //'gpt-5-mini
|
|
4767
4908
|
|
|
4768
4909
|
let formattedParams;
|
|
4769
4910
|
if (response_format) {
|
|
@@ -4772,14 +4913,19 @@ export const submit_chat_gpt_prompt = async function (req) {
|
|
|
4772
4913
|
|
|
4773
4914
|
try {
|
|
4774
4915
|
let response;
|
|
4916
|
+
const real_model = resolve_ai_model(model);
|
|
4917
|
+
const eff = effort === undefined ? INTERNAL_AI_EFFORT() : effort;
|
|
4918
|
+
const timeout = ai_timeout_ms(effort, real_model, tools);
|
|
4919
|
+
const started = Date.now();
|
|
4775
4920
|
try {
|
|
4776
4921
|
let opt = {
|
|
4777
|
-
model:
|
|
4922
|
+
model: real_model,
|
|
4778
4923
|
input: prompt,
|
|
4779
4924
|
tools,
|
|
4780
4925
|
text: {
|
|
4781
4926
|
format: formattedParams,
|
|
4782
4927
|
},
|
|
4928
|
+
...reasoning_opt(real_model, effort),
|
|
4783
4929
|
};
|
|
4784
4930
|
if (conversation_id) {
|
|
4785
4931
|
opt.conversationId = conversation_id;
|
|
@@ -4787,10 +4933,25 @@ export const submit_chat_gpt_prompt = async function (req) {
|
|
|
4787
4933
|
if (response_id) {
|
|
4788
4934
|
opt.previous_response_id = response_id;
|
|
4789
4935
|
}
|
|
4790
|
-
|
|
4936
|
+
// Second argument is per-request options, not part of the body. The SDK's own
|
|
4937
|
+
// maxRetries (2) treats a timeout as retryable, which is the whole point: a
|
|
4938
|
+
// request the OpenAI edge has gone silent on is abandoned and re-asked instead
|
|
4939
|
+
// of waited out.
|
|
4940
|
+
response = await client.responses.create(opt, { timeout });
|
|
4791
4941
|
report_ai_status(model);
|
|
4942
|
+
// No timing existed on this path, so "the AI is slow" was never anything we
|
|
4943
|
+
// could point at a number. One line per call, only when it actually dragged.
|
|
4944
|
+
const ms = Date.now() - started;
|
|
4945
|
+
if (ms > (_conf.ai_slow_log_ms || 5000)) {
|
|
4946
|
+
console.warn(`[ai] slow prompt ${ms}ms model=${real_model} effort=${eff} timeout=${timeout}ms reasoning_tokens=${response?.usage?.output_tokens_details?.reasoning_tokens ?? '?'} func=${metadata?.func || '-'}`);
|
|
4947
|
+
}
|
|
4792
4948
|
} catch (err) {
|
|
4793
4949
|
report_ai_status(model, err);
|
|
4950
|
+
// A timeout here means every attempt was abandoned, so say so plainly rather
|
|
4951
|
+
// than handing the caller the SDK's generic connection-error wording.
|
|
4952
|
+
if (/timed? ?out/i.test(err?.message || '') || err?.name === 'APIConnectionTimeoutError') {
|
|
4953
|
+
console.warn(`[ai] prompt timed out after ${Date.now() - started}ms model=${real_model} effort=${eff} timeout=${timeout}ms func=${metadata?.func || '-'}`);
|
|
4954
|
+
}
|
|
4794
4955
|
throw err;
|
|
4795
4956
|
}
|
|
4796
4957
|
account_msa.record_ai_usage(uid, response.usage.input_tokens, response.usage.output_tokens, 'submit chat', prompt, model, metadata, account_profile_info, tools);
|
|
@@ -5017,6 +5178,9 @@ export const ai_field_assist = async function (req) {
|
|
|
5017
5178
|
uid,
|
|
5018
5179
|
prompt: parts.join('\n'),
|
|
5019
5180
|
model,
|
|
5181
|
+
// Sparkle-icon writing: the user is watching, and the per-field rules above are
|
|
5182
|
+
// explicit enough that hidden reasoning adds latency, not quality.
|
|
5183
|
+
effort: _conf.field_assist?.effort || 'low',
|
|
5020
5184
|
metadata: { func: 'ai_field_assist', field, mode },
|
|
5021
5185
|
account_profile_info,
|
|
5022
5186
|
});
|
|
@@ -5169,6 +5333,10 @@ export const triage_error_incident = async function (req) {
|
|
|
5169
5333
|
prompt,
|
|
5170
5334
|
response_format: verdict_schema,
|
|
5171
5335
|
uid: _conf.superuser_account_ids?.[0],
|
|
5336
|
+
// Root-causing an incident is the one thing here worth thinking about, and it
|
|
5337
|
+
// runs on a cron where nobody is watching a spinner, so it opts out of the
|
|
5338
|
+
// 'minimal' default.
|
|
5339
|
+
effort: _conf.error_resolver?.effort || 'medium',
|
|
5172
5340
|
metadata: { func: 'triage_error_incident', signature: incident?.signature, code: incident?.code },
|
|
5173
5341
|
});
|
|
5174
5342
|
|
|
@@ -5225,6 +5393,8 @@ export const diagnose_vps_snapshot = async function (req) {
|
|
|
5225
5393
|
prompt: parts.join('\n'),
|
|
5226
5394
|
response_format: verdict_schema,
|
|
5227
5395
|
uid,
|
|
5396
|
+
// Reading a server snapshot is analysis, not a one-liner, and it runs unattended.
|
|
5397
|
+
effort: _conf.auto_diagnose?.effort || 'medium',
|
|
5228
5398
|
metadata: { func: 'diagnose_vps_snapshot', app_name },
|
|
5229
5399
|
});
|
|
5230
5400
|
|
|
@@ -5374,6 +5544,9 @@ export const generate_release_notes = async function (req) {
|
|
|
5374
5544
|
uid,
|
|
5375
5545
|
prompt: parts.join('\n'),
|
|
5376
5546
|
model: _conf.release_notes?.model || _conf.default_ai_model,
|
|
5547
|
+
// Turning a changelog into readable notes is a writing job, so give it a little
|
|
5548
|
+
// more than 'minimal' without paying for full reasoning.
|
|
5549
|
+
effort: _conf.release_notes?.effort || 'low',
|
|
5377
5550
|
metadata: { func: 'generate_release_notes', app_id: app_ref, version },
|
|
5378
5551
|
account_profile_info,
|
|
5379
5552
|
});
|
|
@@ -6433,6 +6606,10 @@ Rules:
|
|
|
6433
6606
|
prompt,
|
|
6434
6607
|
model: ai_model || _conf.default_ai_model,
|
|
6435
6608
|
response_format: ComposedEmailSchema,
|
|
6609
|
+
// The "Writing your email..." spinner. It carries a long rule list, so it gets
|
|
6610
|
+
// 'low' rather than 'minimal' to keep instruction-following tight; that is still
|
|
6611
|
+
// a few seconds instead of the 10-16s the unset default was costing.
|
|
6612
|
+
effort: _conf.compose_email_effort || 'low',
|
|
6436
6613
|
metadata: { contact_id, func: 'compose_contact_email' },
|
|
6437
6614
|
account_profile_info,
|
|
6438
6615
|
});
|
|
@@ -6466,7 +6643,14 @@ Rules:
|
|
|
6466
6643
|
};
|
|
6467
6644
|
|
|
6468
6645
|
const chat_email = async function (req, job_id, headers) {
|
|
6469
|
-
const { profile_id, uid, email_id, perform_ai_execution = true, from_mailbox, _thread_reentry
|
|
6646
|
+
const { profile_id, uid, email_id, perform_ai_execution = true, from_mailbox, _thread_reentry } = req;
|
|
6647
|
+
// A send a person made in the app is outbound by definition. Only the mailbox path can
|
|
6648
|
+
// produce an inbound item and it always says which way the mail went (email_module reads
|
|
6649
|
+
// it off is_sent), so an absent direction here means a user-invoked send: the composer,
|
|
6650
|
+
// or any future caller that forgets. It used to be written through as undefined, which
|
|
6651
|
+
// left the key off the saved item entirely and made a mail we sent indistinguishable
|
|
6652
|
+
// from one we received, on the timeline and in the AI's own reading of the thread.
|
|
6653
|
+
const direction = req.direction || 'out';
|
|
6470
6654
|
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
6471
6655
|
let { prompt: body, conversation_doc, attachments = [], ai_agents } = req;
|
|
6472
6656
|
// UI-126 (b): a composed send arrives already written and already reviewed by the user, so
|
|
@@ -6474,6 +6658,9 @@ const chat_email = async function (req, job_id, headers) {
|
|
|
6474
6658
|
// path, and every inbound/auto reply) leaves both empty and behaves exactly as before.
|
|
6475
6659
|
const composed_subject = String(req.subject || '').trim();
|
|
6476
6660
|
const composed_html = _sanitize_email_html(req.body_html);
|
|
6661
|
+
// UI-155: the template the user picked in the composer, for THIS message only. Empty on
|
|
6662
|
+
// every other path, which leaves the address's saved default in charge.
|
|
6663
|
+
const composed_style = String(req.template_style || '').trim();
|
|
6477
6664
|
try {
|
|
6478
6665
|
if (!account_profile_info.account_profile_obj?.email_account_id) {
|
|
6479
6666
|
throw await email_binding_error(account_profile_info);
|
|
@@ -6482,9 +6669,26 @@ const chat_email = async function (req, job_id, headers) {
|
|
|
6482
6669
|
const conversation_id = conversation_doc._id;
|
|
6483
6670
|
const sender_app_id = account_profile_info.app_id;
|
|
6484
6671
|
|
|
6485
|
-
|
|
6672
|
+
// UI-161: both of these used to fail as a bare CouchDB `{ message: 'missing' }` with no
|
|
6673
|
+
// clue which id or which database was involved, and the job died there, so an email was
|
|
6674
|
+
// never sent and nothing on screen said why. Each lookup now names what it could not
|
|
6675
|
+
// find. Boaz: "i sent test email to B / it hung".
|
|
6676
|
+
const email_account_id = account_profile_info.account_profile_obj?.email_account_id;
|
|
6677
|
+
let email_account_doc;
|
|
6678
|
+
try {
|
|
6679
|
+
email_account_doc = await db_module.get_app_couch_doc_native(sender_app_id, email_account_id);
|
|
6680
|
+
} catch (err) {
|
|
6681
|
+
console.error(`[chat_email] mailbox doc not found: app_id=${sender_app_id} email_account_id=${email_account_id} profile=${account_profile_info.account_profile_obj?._id}`, err?.message || err);
|
|
6682
|
+
throw new Error(`The mailbox attached to this profile could not be loaded (${email_account_id}). Open Email, go to Profiles, and re-attach it.`);
|
|
6683
|
+
}
|
|
6486
6684
|
|
|
6487
|
-
let sender_conversation_doc
|
|
6685
|
+
let sender_conversation_doc;
|
|
6686
|
+
try {
|
|
6687
|
+
sender_conversation_doc = await db_module.get_app_couch_doc_native(sender_app_id, conversation_id);
|
|
6688
|
+
} catch (err) {
|
|
6689
|
+
console.error(`[chat_email] conversation doc not found: app_id=${sender_app_id} conversation_id=${conversation_id}`, err?.message || err);
|
|
6690
|
+
throw new Error(`This conversation could not be loaded (${conversation_id}).`);
|
|
6691
|
+
}
|
|
6488
6692
|
|
|
6489
6693
|
if (sender_conversation_doc.reference_type !== 'contacts') {
|
|
6490
6694
|
throw new Error('not an contact conversation');
|
|
@@ -6566,6 +6770,9 @@ const chat_email = async function (req, job_id, headers) {
|
|
|
6566
6770
|
uid,
|
|
6567
6771
|
email_account_id: profile_doc.email_account_id,
|
|
6568
6772
|
style: profile_doc.email_template?.style,
|
|
6773
|
+
// UI-155: a per-message choice made in the composer, which outranks the address's
|
|
6774
|
+
// saved default inside the renderer.
|
|
6775
|
+
...(composed_style ? { style_override: composed_style } : {}),
|
|
6569
6776
|
body_text: body,
|
|
6570
6777
|
// A composed body is already HTML the user laid out (bold, lists, links), so the
|
|
6571
6778
|
// template has to drop it in as-is. Escaping it into paragraphs the way a plain
|
|
@@ -6582,7 +6789,12 @@ const chat_email = async function (req, job_id, headers) {
|
|
|
6582
6789
|
// With no template style selected there is nothing wrapping the body, so the composed
|
|
6583
6790
|
// HTML is the whole message. Without this it would fall through to sendEmailFromAccount's
|
|
6584
6791
|
// "wrap the plain text in one <p>" default and the formatting would be lost.
|
|
6585
|
-
|
|
6792
|
+
// UI-157: cc / bcc come from the composer and are normalized inside sendEmailFromAccount,
|
|
6793
|
+
// so anything that is not an address is dropped rather than reaching the SMTP server.
|
|
6794
|
+
sent_email_result = await email_ms.sendEmailFromAccount(email_account_doc, contact_info.email, subject, body, template_html || composed_html || null, email_attachments, {
|
|
6795
|
+
cc: req.cc,
|
|
6796
|
+
bcc: req.bcc,
|
|
6797
|
+
});
|
|
6586
6798
|
if (!sent_email_result.success) {
|
|
6587
6799
|
throw new Error('error sending email');
|
|
6588
6800
|
}
|
|
@@ -6647,6 +6859,17 @@ const chat_email = async function (req, job_id, headers) {
|
|
|
6647
6859
|
|
|
6648
6860
|
return save_ret;
|
|
6649
6861
|
} catch (err) {
|
|
6862
|
+
// UI-161: this used to swallow the reason, so a send that died anywhere in here surfaced
|
|
6863
|
+
// as a bare CouchDB `{ message: 'missing' }` on stderr with nothing tying it to a
|
|
6864
|
+
// conversation, a contact or a mailbox. The job then ended and no email went out.
|
|
6865
|
+
console.error(`[chat_email] failed: conversation_id=${conversation_doc?._id} contact=${conversation_doc?.reference_id} app_id=${account_profile_info?.app_id} profile=${account_profile_info?.account_profile_obj?._id} mailbox=${account_profile_info?.account_profile_obj?.email_account_id}`, err?.stack || err?.message || err);
|
|
6866
|
+
// UI-161: an expired or revoked OAuth grant is the single most common way sending stops
|
|
6867
|
+
// working, and "Failed to obtain valid access token" tells the person reading it nothing
|
|
6868
|
+
// about what to do. Name the mailbox and the fix.
|
|
6869
|
+
if (/access token|invalid_grant|unauthorized|invalid credentials/i.test(String(err?.message || ''))) {
|
|
6870
|
+
const mailbox_address = email_account_doc?.email || account_profile_info?.account_profile_obj?.email_account_id || 'this mailbox';
|
|
6871
|
+
return { code: -15, data: `Xuda can no longer sign in to ${mailbox_address}, so the email was not sent. Open Email, go to Mailboxes, and reconnect the account.` };
|
|
6872
|
+
}
|
|
6650
6873
|
return { code: -15, data: err.message };
|
|
6651
6874
|
}
|
|
6652
6875
|
};
|
|
@@ -6906,11 +7129,8 @@ ${conversation_history || `User (studio): ${prompt}`}
|
|
|
6906
7129
|
|
|
6907
7130
|
thinking_index += 1;
|
|
6908
7131
|
try {
|
|
6909
|
-
if (job_id) {
|
|
6910
|
-
|
|
6911
|
-
if (job_info.code < 0 || job_info.data.stat === 4) {
|
|
6912
|
-
return;
|
|
6913
|
-
}
|
|
7132
|
+
if (await is_job_aborted(job_id)) {
|
|
7133
|
+
return;
|
|
6914
7134
|
}
|
|
6915
7135
|
} catch (error) {}
|
|
6916
7136
|
|
|
@@ -7952,15 +8172,22 @@ Available CPI methods: ${cpi_tools_ret.selected_methods.join(', ')}${dashboard_s
|
|
|
7952
8172
|
if (stream) {
|
|
7953
8173
|
const output_stream = output.toTextStream({ compatibleWithNodeStreams: true });
|
|
7954
8174
|
let response_started = false;
|
|
7955
|
-
|
|
7956
|
-
|
|
7957
|
-
|
|
7958
|
-
|
|
7959
|
-
|
|
8175
|
+
// Same Stop watch the contact/agent chat runs. Without it the dashboard chat streamed
|
|
8176
|
+
// to the end no matter what the user pressed.
|
|
8177
|
+
const stop_abort_watch = watch_job_abort(job_id, output_stream);
|
|
8178
|
+
try {
|
|
8179
|
+
for await (const chunk of output_stream) {
|
|
8180
|
+
if (!response_started) {
|
|
8181
|
+
response_started = true;
|
|
8182
|
+
emitToDashboard('stream_phase', 'Streaming results', { update: true });
|
|
8183
|
+
emitToDashboard('response_start');
|
|
8184
|
+
}
|
|
8185
|
+
const text = chunk.toString();
|
|
8186
|
+
response_text += text;
|
|
8187
|
+
emitToDashboard('stream_delta', text);
|
|
7960
8188
|
}
|
|
7961
|
-
|
|
7962
|
-
|
|
7963
|
-
emitToDashboard('stream_delta', text);
|
|
8189
|
+
} finally {
|
|
8190
|
+
stop_abort_watch();
|
|
7964
8191
|
}
|
|
7965
8192
|
}
|
|
7966
8193
|
|
|
@@ -8004,6 +8231,26 @@ Available CPI methods: ${cpi_tools_ret.selected_methods.join(', ')}${dashboard_s
|
|
|
8004
8231
|
} catch (e) {}
|
|
8005
8232
|
return { code: -1, data: { error: 'credit_limit', account_id: err.account_id } };
|
|
8006
8233
|
}
|
|
8234
|
+
// The user pressed Stop. That is not a failure: say so plainly, flag the stream
|
|
8235
|
+
// end as aborted (the chat-finished alert reads that flag to stay quiet) and skip
|
|
8236
|
+
// the error card and the error log.
|
|
8237
|
+
if ((typeof err === 'string' ? err : err?.message) === 'aborted') {
|
|
8238
|
+
emitToDashboard('stream_phase', 'Stopped', { update: true });
|
|
8239
|
+
emitToDashboard('response_start');
|
|
8240
|
+
streamText('Stopped.');
|
|
8241
|
+
emitToDashboard('stream_end', undefined, { aborted: true });
|
|
8242
|
+
|
|
8243
|
+
try {
|
|
8244
|
+
conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_id);
|
|
8245
|
+
conversation_doc.ts = Date.now();
|
|
8246
|
+
conversation_doc.stat = 3;
|
|
8247
|
+
conversation_doc.process_stat = 'partial';
|
|
8248
|
+
await db_module.save_app_couch_doc_native(account_profile_info.app_id, conversation_doc);
|
|
8249
|
+
} catch (e) {}
|
|
8250
|
+
|
|
8251
|
+
return { code: -4, data: 'aborted' };
|
|
8252
|
+
}
|
|
8253
|
+
|
|
8007
8254
|
const error_message = get_error_message(err, 'dashboard request failed');
|
|
8008
8255
|
// Never echo the raw error to the client — it can leak provider/quota/host
|
|
8009
8256
|
// details (e.g. the OpenAI 429 quota text). Log it; show a clean card via the
|
|
@@ -8997,7 +9244,7 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
8997
9244
|
let interval = setInterval(async function () {
|
|
8998
9245
|
const job_info = await jobs_ms.get_job_info({ job_id });
|
|
8999
9246
|
|
|
9000
|
-
if (job_info.code < 0 || job_info.data.stat === 4) {
|
|
9247
|
+
if (job_info.code < 0 || job_info?.data?.abort === true || job_info.data.stat === 4) {
|
|
9001
9248
|
clearInterval(interval);
|
|
9002
9249
|
resolve({ code: -1, data: 'aborted' });
|
|
9003
9250
|
return;
|
|
@@ -9269,7 +9516,7 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
9269
9516
|
clearInterval(interval);
|
|
9270
9517
|
resolve(null);
|
|
9271
9518
|
} else {
|
|
9272
|
-
if (job_info.code < 0 || job_info.data.stat === 4) {
|
|
9519
|
+
if (job_info.code < 0 || job_info?.data?.abort === true || job_info.data.stat === 4) {
|
|
9273
9520
|
reject('aborted');
|
|
9274
9521
|
clearInterval(interval);
|
|
9275
9522
|
return;
|
|
@@ -9293,9 +9540,7 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
9293
9540
|
const run_agent = function (_agent, prompt, opt) {
|
|
9294
9541
|
return new Promise(async (resolve, reject) => {
|
|
9295
9542
|
let interval = setInterval(async function () {
|
|
9296
|
-
|
|
9297
|
-
|
|
9298
|
-
if (job_info.code < 0 || job_info.data.stat === 4) {
|
|
9543
|
+
if (await is_job_aborted(job_id)) {
|
|
9299
9544
|
reject(new Error('aborted'));
|
|
9300
9545
|
clearInterval(interval);
|
|
9301
9546
|
}
|
|
@@ -9478,29 +9723,28 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
9478
9723
|
|
|
9479
9724
|
// 1. Consume the stream exactly once
|
|
9480
9725
|
let string_debug = '';
|
|
9481
|
-
|
|
9482
|
-
|
|
9483
|
-
|
|
9484
|
-
|
|
9485
|
-
|
|
9486
|
-
|
|
9487
|
-
|
|
9488
|
-
|
|
9489
|
-
}
|
|
9490
|
-
chunks++;
|
|
9491
|
-
if (!response_start) {
|
|
9492
|
-
response_start = true;
|
|
9726
|
+
// Stop is watched on its own timer rather than per chunk: a chunk-counted check is
|
|
9727
|
+
// blind while the model is thinking, and it put a broker round trip in the middle of
|
|
9728
|
+
// the stream. The watch cuts the reader, which throws into the abort path below.
|
|
9729
|
+
const stop_abort_watch = watch_job_abort(job_id, output_stream);
|
|
9730
|
+
try {
|
|
9731
|
+
for await (const chunk of output_stream) {
|
|
9732
|
+
if (!response_start) {
|
|
9733
|
+
response_start = true;
|
|
9493
9734
|
|
|
9494
|
-
|
|
9495
|
-
|
|
9496
|
-
|
|
9735
|
+
// await update_job('streaming results');
|
|
9736
|
+
emitToDashboard('stream_phase', 'Streaming results');
|
|
9737
|
+
emitToDashboard('response_start');
|
|
9738
|
+
}
|
|
9739
|
+
const text = chunk.toString();
|
|
9740
|
+
// console.log('[CHUNK]', text);
|
|
9741
|
+
emitToDashboard('stream_delta', text);
|
|
9742
|
+
string_debug += text;
|
|
9743
|
+
// Accumulate it
|
|
9744
|
+
buffer += chunk;
|
|
9497
9745
|
}
|
|
9498
|
-
|
|
9499
|
-
|
|
9500
|
-
emitToDashboard('stream_delta', text);
|
|
9501
|
-
string_debug += text;
|
|
9502
|
-
// Accumulate it
|
|
9503
|
-
buffer += chunk;
|
|
9746
|
+
} finally {
|
|
9747
|
+
stop_abort_watch();
|
|
9504
9748
|
}
|
|
9505
9749
|
// console.log('string_debug', string_debug);
|
|
9506
9750
|
emitToDashboard('stream_end');
|
|
@@ -9538,7 +9782,11 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
9538
9782
|
emitToDashboard(
|
|
9539
9783
|
'stream_delta',
|
|
9540
9784
|
aborted
|
|
9541
|
-
?
|
|
9785
|
+
? // Mid-answer, this delta lands straight onto the last word the model wrote
|
|
9786
|
+
// ("...movable typeStopped."), so break the line first.
|
|
9787
|
+
response_started
|
|
9788
|
+
? '\n\nStopped.'
|
|
9789
|
+
: 'Stopped.'
|
|
9542
9790
|
: reason === 'agent_unavailable'
|
|
9543
9791
|
? "This agent isn't available right now. Please try again in a moment."
|
|
9544
9792
|
: "I couldn't complete that just now. Please try again in a moment.",
|