@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.
Files changed (2) hide show
  1. package/index.mjs +295 -47
  2. 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: resolve_ai_model(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
- response = await client.responses.create(opt);
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, direction } = req;
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
- let email_account_doc = await db_module.get_app_couch_doc_native(sender_app_id, account_profile_info.account_profile_obj?.email_account_id);
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 = await db_module.get_app_couch_doc_native(sender_app_id, conversation_id);
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
- sent_email_result = await email_ms.sendEmailFromAccount(email_account_doc, contact_info.email, subject, body, template_html || composed_html || null, email_attachments);
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
- const job_info = await jobs_ms.get_job_info({ job_id });
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
- for await (const chunk of output_stream) {
7956
- if (!response_started) {
7957
- response_started = true;
7958
- emitToDashboard('stream_phase', 'Streaming results', { update: true });
7959
- emitToDashboard('response_start');
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
- const text = chunk.toString();
7962
- response_text += text;
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
- const job_info = await jobs_ms.get_job_info({ job_id });
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
- let chunks = 0;
9482
- for await (const chunk of output_stream) {
9483
- // console.log(chunks);
9484
- if (chunks % 10 === 0) {
9485
- const job_info = await jobs_ms.get_job_info({ job_id });
9486
- if (job_info.code < 0 || job_info.data.stat === 4) {
9487
- throw 'aborted';
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
- // await update_job('streaming results');
9495
- emitToDashboard('stream_phase', 'Streaming results');
9496
- emitToDashboard('response_start');
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
- const text = chunk.toString();
9499
- // console.log('[CHUNK]', text);
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
- ? 'Stopped.'
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.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/ai_module",
3
- "version": "1.1.5651",
3
+ "version": "1.1.5653",
4
4
  "description": "Xuda AI Module",
5
5
  "main": "index.mjs",
6
6
  "type": "module",