@xuda.io/ai_module 1.1.5652 → 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 +188 -43
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -426,6 +426,53 @@ const account_msa = await import(`${module_path}/account_module/index_msa.mjs`);
|
|
|
426
426
|
const drive_msa = await import(`${module_path}/drive_module/index_msa.mjs`);
|
|
427
427
|
const misc_msa = await import(`${module_path}/misc_module/index_msa.mjs`);
|
|
428
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
|
+
|
|
429
476
|
var open_ai_status = {};
|
|
430
477
|
const report_ai_status = function (model, err) {
|
|
431
478
|
open_ai_status[model] = { stat: err ? 'error' : 'ok', date: new Date(), err };
|
|
@@ -1521,6 +1568,41 @@ const chat_finished_summary = function (text) {
|
|
|
1521
1568
|
return plain.length > 160 ? `${plain.slice(0, 157)}...` : plain;
|
|
1522
1569
|
};
|
|
1523
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
|
+
|
|
1524
1606
|
// Some flows can reach a second terminal event in one turn (a stream that completed and
|
|
1525
1607
|
// then failed while its result was being persisted), and each one closes the stream. The
|
|
1526
1608
|
// user only wants to be told once per chat, so keep the last alert per conversation and
|
|
@@ -1547,6 +1629,7 @@ const notify_chat_finished = async function ({ uid, conversation_id, conversatio
|
|
|
1547
1629
|
if (!presence || presence.code < 0 || presence.data !== false) return;
|
|
1548
1630
|
|
|
1549
1631
|
const title = String(conversation_doc?.title || '').trim();
|
|
1632
|
+
const image = await chat_finished_image(uid, conversation_doc);
|
|
1550
1633
|
|
|
1551
1634
|
// Stamped only now that an alert is really going out. Stamping every finished run
|
|
1552
1635
|
// would let a run nobody needed to hear about (the user was watching it) silence the
|
|
@@ -1563,6 +1646,8 @@ const notify_chat_finished = async function ({ uid, conversation_id, conversatio
|
|
|
1563
1646
|
uid_arr: [uid],
|
|
1564
1647
|
subject: title ? `Ready: ${title}` : 'Your chat is ready',
|
|
1565
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 } : {}),
|
|
1566
1651
|
delivery_method: ['push'],
|
|
1567
1652
|
display_type: 'info',
|
|
1568
1653
|
ref: conversation_id,
|
|
@@ -6558,7 +6643,14 @@ Rules:
|
|
|
6558
6643
|
};
|
|
6559
6644
|
|
|
6560
6645
|
const chat_email = async function (req, job_id, headers) {
|
|
6561
|
-
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';
|
|
6562
6654
|
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
6563
6655
|
let { prompt: body, conversation_doc, attachments = [], ai_agents } = req;
|
|
6564
6656
|
// UI-126 (b): a composed send arrives already written and already reviewed by the user, so
|
|
@@ -6577,9 +6669,26 @@ const chat_email = async function (req, job_id, headers) {
|
|
|
6577
6669
|
const conversation_id = conversation_doc._id;
|
|
6578
6670
|
const sender_app_id = account_profile_info.app_id;
|
|
6579
6671
|
|
|
6580
|
-
|
|
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
|
+
}
|
|
6581
6684
|
|
|
6582
|
-
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
|
+
}
|
|
6583
6692
|
|
|
6584
6693
|
if (sender_conversation_doc.reference_type !== 'contacts') {
|
|
6585
6694
|
throw new Error('not an contact conversation');
|
|
@@ -6750,6 +6859,17 @@ const chat_email = async function (req, job_id, headers) {
|
|
|
6750
6859
|
|
|
6751
6860
|
return save_ret;
|
|
6752
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
|
+
}
|
|
6753
6873
|
return { code: -15, data: err.message };
|
|
6754
6874
|
}
|
|
6755
6875
|
};
|
|
@@ -7009,11 +7129,8 @@ ${conversation_history || `User (studio): ${prompt}`}
|
|
|
7009
7129
|
|
|
7010
7130
|
thinking_index += 1;
|
|
7011
7131
|
try {
|
|
7012
|
-
if (job_id) {
|
|
7013
|
-
|
|
7014
|
-
if (job_info.code < 0 || job_info.data.stat === 4) {
|
|
7015
|
-
return;
|
|
7016
|
-
}
|
|
7132
|
+
if (await is_job_aborted(job_id)) {
|
|
7133
|
+
return;
|
|
7017
7134
|
}
|
|
7018
7135
|
} catch (error) {}
|
|
7019
7136
|
|
|
@@ -8055,15 +8172,22 @@ Available CPI methods: ${cpi_tools_ret.selected_methods.join(', ')}${dashboard_s
|
|
|
8055
8172
|
if (stream) {
|
|
8056
8173
|
const output_stream = output.toTextStream({ compatibleWithNodeStreams: true });
|
|
8057
8174
|
let response_started = false;
|
|
8058
|
-
|
|
8059
|
-
|
|
8060
|
-
|
|
8061
|
-
|
|
8062
|
-
|
|
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);
|
|
8063
8188
|
}
|
|
8064
|
-
|
|
8065
|
-
|
|
8066
|
-
emitToDashboard('stream_delta', text);
|
|
8189
|
+
} finally {
|
|
8190
|
+
stop_abort_watch();
|
|
8067
8191
|
}
|
|
8068
8192
|
}
|
|
8069
8193
|
|
|
@@ -8107,6 +8231,26 @@ Available CPI methods: ${cpi_tools_ret.selected_methods.join(', ')}${dashboard_s
|
|
|
8107
8231
|
} catch (e) {}
|
|
8108
8232
|
return { code: -1, data: { error: 'credit_limit', account_id: err.account_id } };
|
|
8109
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
|
+
|
|
8110
8254
|
const error_message = get_error_message(err, 'dashboard request failed');
|
|
8111
8255
|
// Never echo the raw error to the client — it can leak provider/quota/host
|
|
8112
8256
|
// details (e.g. the OpenAI 429 quota text). Log it; show a clean card via the
|
|
@@ -9100,7 +9244,7 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
9100
9244
|
let interval = setInterval(async function () {
|
|
9101
9245
|
const job_info = await jobs_ms.get_job_info({ job_id });
|
|
9102
9246
|
|
|
9103
|
-
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) {
|
|
9104
9248
|
clearInterval(interval);
|
|
9105
9249
|
resolve({ code: -1, data: 'aborted' });
|
|
9106
9250
|
return;
|
|
@@ -9372,7 +9516,7 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
9372
9516
|
clearInterval(interval);
|
|
9373
9517
|
resolve(null);
|
|
9374
9518
|
} else {
|
|
9375
|
-
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) {
|
|
9376
9520
|
reject('aborted');
|
|
9377
9521
|
clearInterval(interval);
|
|
9378
9522
|
return;
|
|
@@ -9396,9 +9540,7 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
9396
9540
|
const run_agent = function (_agent, prompt, opt) {
|
|
9397
9541
|
return new Promise(async (resolve, reject) => {
|
|
9398
9542
|
let interval = setInterval(async function () {
|
|
9399
|
-
|
|
9400
|
-
|
|
9401
|
-
if (job_info.code < 0 || job_info.data.stat === 4) {
|
|
9543
|
+
if (await is_job_aborted(job_id)) {
|
|
9402
9544
|
reject(new Error('aborted'));
|
|
9403
9545
|
clearInterval(interval);
|
|
9404
9546
|
}
|
|
@@ -9581,29 +9723,28 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
9581
9723
|
|
|
9582
9724
|
// 1. Consume the stream exactly once
|
|
9583
9725
|
let string_debug = '';
|
|
9584
|
-
|
|
9585
|
-
|
|
9586
|
-
|
|
9587
|
-
|
|
9588
|
-
|
|
9589
|
-
|
|
9590
|
-
|
|
9591
|
-
|
|
9592
|
-
}
|
|
9593
|
-
chunks++;
|
|
9594
|
-
if (!response_start) {
|
|
9595
|
-
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;
|
|
9596
9734
|
|
|
9597
|
-
|
|
9598
|
-
|
|
9599
|
-
|
|
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;
|
|
9600
9745
|
}
|
|
9601
|
-
|
|
9602
|
-
|
|
9603
|
-
emitToDashboard('stream_delta', text);
|
|
9604
|
-
string_debug += text;
|
|
9605
|
-
// Accumulate it
|
|
9606
|
-
buffer += chunk;
|
|
9746
|
+
} finally {
|
|
9747
|
+
stop_abort_watch();
|
|
9607
9748
|
}
|
|
9608
9749
|
// console.log('string_debug', string_debug);
|
|
9609
9750
|
emitToDashboard('stream_end');
|
|
@@ -9641,7 +9782,11 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
9641
9782
|
emitToDashboard(
|
|
9642
9783
|
'stream_delta',
|
|
9643
9784
|
aborted
|
|
9644
|
-
?
|
|
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.'
|
|
9645
9790
|
: reason === 'agent_unavailable'
|
|
9646
9791
|
? "This agent isn't available right now. Please try again in a moment."
|
|
9647
9792
|
: "I couldn't complete that just now. Please try again in a moment.",
|