@xuda.io/ai_module 1.1.5653 → 1.1.5655
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 +1444 -209
- package/index_ms.mjs +20 -0
- package/index_msa.mjs +20 -0
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -435,11 +435,24 @@ const misc_msa = await import(`${module_path}/misc_module/index_msa.mjs`);
|
|
|
435
435
|
// stat that could not change, and Stop did nothing but leave the response
|
|
436
436
|
// streaming. Read the flag itself, and keep the stat + "job vanished" cases so
|
|
437
437
|
// non-chat runs (which do go through update_job) behave exactly as before.
|
|
438
|
+
// Only an EXPLICIT stop counts. `abort_job` (jobs_module) records a Stop by writing
|
|
439
|
+
// `abort: true` onto the job, and stat 4 is the cancelled state, so those two are the whole
|
|
440
|
+
// signal. "I could not read the job" is not one of them, and it used to be: a `code < 0` from
|
|
441
|
+
// get_job_info was read as aborted and killed the run on the spot.
|
|
442
|
+
//
|
|
443
|
+
// That is reachable, not theoretical. jobs_module stores a job in memcached under a 600s TTL
|
|
444
|
+
// refreshed only by a progress write, and the chat path's update_job calls are commented out
|
|
445
|
+
// in favour of stream_phase, so a run stops being findable after ten minutes. Everything past
|
|
446
|
+
// that point (a long tool chain, a slow generation, a retry) was cut off mid-answer and
|
|
447
|
+
// reported to the user as "Stopped." as though they had pressed the button themselves. An
|
|
448
|
+
// unreadable job now simply means "no stop was requested": the worst case is a run that
|
|
449
|
+
// finishes work nobody is waiting for any more, which is a great deal better than silently
|
|
450
|
+
// truncating one somebody is.
|
|
438
451
|
const is_job_aborted = async function (job_id) {
|
|
439
452
|
if (!job_id) return false;
|
|
440
453
|
try {
|
|
441
454
|
const job_info = await jobs_ms.get_job_info({ job_id });
|
|
442
|
-
return job_info
|
|
455
|
+
return job_info?.data?.abort === true || job_info?.data?.stat === 4;
|
|
443
456
|
} catch (err) {
|
|
444
457
|
return false;
|
|
445
458
|
}
|
|
@@ -510,6 +523,64 @@ try {
|
|
|
510
523
|
}
|
|
511
524
|
const model = _conf.default_ai_model;
|
|
512
525
|
|
|
526
|
+
// A run that dies between the model asking for a tool and that tool's output being written
|
|
527
|
+
// leaves a `function_call` in the OpenAI conversation with no `function_call_output` against
|
|
528
|
+
// it, and the Responses API then rejects the WHOLE conversation, for ever:
|
|
529
|
+
// "400 No tool output found for function call call_...". The thread is not degraded, it is
|
|
530
|
+
// finished. Every later turn fails identically, Retry included, and there is nothing the user
|
|
531
|
+
// can do from the chat to recover it. Boaz hit this on a Shorts Maker thread whose
|
|
532
|
+
// modify_short call was cut off: three sends in a row came back as error cards.
|
|
533
|
+
//
|
|
534
|
+
// Interruptions are not rare (Stop, a pm2 restart mid-run, a tool that throws, a job whose
|
|
535
|
+
// record expired), so repair on the way IN rather than waiting for the failure: drop any
|
|
536
|
+
// function_call that never got its output and the thread carries on from the last consistent
|
|
537
|
+
// point, with the model simply deciding again what to call. Deleting the call rather than
|
|
538
|
+
// inventing an output for it is deliberate, an invented output is a lie the model then reasons
|
|
539
|
+
// from, while a dropped call is simply a decision it never made.
|
|
540
|
+
//
|
|
541
|
+
// `items` is passed in by callers that were already listing them (ai_chat_conversation fetches
|
|
542
|
+
// the page anyway for its `after` cursor, so there it costs nothing) and fetched here for
|
|
543
|
+
// callers that were not. Sound on a partial page either way: an output is always NEWER than its
|
|
544
|
+
// call and the page is in `desc` order, so any function_call visible in the window has its
|
|
545
|
+
// output visible as well if it ever had one.
|
|
546
|
+
//
|
|
547
|
+
// Never throws. A thread that cannot be repaired still gets its turn, and just fails the way it
|
|
548
|
+
// already did. Returns the items that survived, so a caller reading a cursor off them cannot
|
|
549
|
+
// end up pointing at something this just deleted.
|
|
550
|
+
const drop_orphaned_tool_calls = async function (conversation_reference_id, items) {
|
|
551
|
+
if (!conversation_reference_id) return items || [];
|
|
552
|
+
|
|
553
|
+
let list = items;
|
|
554
|
+
if (!list) {
|
|
555
|
+
try {
|
|
556
|
+
const ret = await client.conversations.items.list(conversation_reference_id, { order: 'desc' });
|
|
557
|
+
list = ret?.data || [];
|
|
558
|
+
} catch (err) {
|
|
559
|
+
console.error(`[drop_orphaned_tool_calls] could not read ${conversation_reference_id}: ${err?.message || err}`);
|
|
560
|
+
return [];
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const answered = new Set();
|
|
565
|
+
for (const item of list) {
|
|
566
|
+
if (item?.type === 'function_call_output' && item.call_id) answered.add(item.call_id);
|
|
567
|
+
}
|
|
568
|
+
const orphans = list.filter((item) => item?.type === 'function_call' && item.call_id && !answered.has(item.call_id));
|
|
569
|
+
if (!orphans.length) return list;
|
|
570
|
+
|
|
571
|
+
const dropped = new Set();
|
|
572
|
+
for (const orphan of orphans) {
|
|
573
|
+
try {
|
|
574
|
+
await client.conversations.items.delete(orphan.id, { conversation_id: conversation_reference_id });
|
|
575
|
+
dropped.add(orphan.id);
|
|
576
|
+
console.log(`[drop_orphaned_tool_calls] dropped orphaned tool call ${orphan.name || ''} ${orphan.call_id} from ${conversation_reference_id}`);
|
|
577
|
+
} catch (err) {
|
|
578
|
+
console.error(`[drop_orphaned_tool_calls] could not drop ${orphan.call_id}: ${err?.message || err}`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return list.filter((item) => !dropped.has(item.id));
|
|
582
|
+
};
|
|
583
|
+
|
|
513
584
|
// UI-138: the catalog code to draw a TRANSPARENT-background image edit with.
|
|
514
585
|
//
|
|
515
586
|
// These edits used to name 'chatgpt-image-latest' directly, but ai_model_aliases maps
|
|
@@ -523,9 +594,17 @@ const transparent_image_model = function () {
|
|
|
523
594
|
return _conf?.transparent_image_model || 'img-1';
|
|
524
595
|
};
|
|
525
596
|
|
|
597
|
+
// UI-210: read the status and the param off whichever shape the error arrives in. The
|
|
598
|
+
// first pass only looked at `err.status` and `err.param`, which is the openai-node
|
|
599
|
+
// BadRequestError shape; the same refusal wrapped by a retry helper or an http client
|
|
600
|
+
// surfaces as `err.response.status` / `err.error.param` and slipped straight past the
|
|
601
|
+
// guard, so the call that was supposed to degrade threw instead. The message test is the
|
|
602
|
+
// backstop for anything that carries neither.
|
|
526
603
|
const _is_transparent_background_rejected = function (err) {
|
|
527
|
-
|
|
528
|
-
|
|
604
|
+
const status = err?.status ?? err?.statusCode ?? err?.response?.status;
|
|
605
|
+
if (status !== 400) return false;
|
|
606
|
+
const param = err?.param ?? err?.error?.param ?? err?.response?.data?.error?.param;
|
|
607
|
+
return param === 'background' || /transparent background is not supported/i.test(err?.message || err?.error?.message || '');
|
|
529
608
|
};
|
|
530
609
|
|
|
531
610
|
// images.edit asking for a transparent background, degrading to an opaque render if the
|
|
@@ -1538,17 +1617,28 @@ const check_studio_doc_tool = tool({
|
|
|
1538
1617
|
// only when ws_dashboard says this user is NOT watching that conversation.
|
|
1539
1618
|
const CHAT_PRESENCE_TIMEOUT_MS = 4000;
|
|
1540
1619
|
|
|
1541
|
-
// The dashboard route is /dashboard/<tab>/<referenceId
|
|
1542
|
-
//
|
|
1543
|
-
//
|
|
1620
|
+
// The dashboard route is /dashboard/<tab>/<referenceId>, and a CONVERSATION is addressed at
|
|
1621
|
+
// /dashboard/ai_chats/<conversation_id> whatever it hangs off: that is the route the
|
|
1622
|
+
// dashboard itself navigates to after the first send inside an agent (AiChat.vue), so it
|
|
1623
|
+
// opens an agent's or a contact's thread just as well as a plain ai_chat.
|
|
1624
|
+
//
|
|
1625
|
+
// UI-187: it used to send anything with a reference to /dashboard/<reference_type>/<id>
|
|
1626
|
+
// instead, so an agent's alert opened the AGENT (its detail view, listing every past chat)
|
|
1627
|
+
// and a contact's opened the CONTACT. The alert says an answer is ready and then landed you
|
|
1628
|
+
// somewhere you still had to go looking for it. Boaz: "clicking on the toast/fcm should open
|
|
1629
|
+
// the conversation".
|
|
1630
|
+
//
|
|
1631
|
+
// Two exceptions keep their entity route because the conversation has no standalone page
|
|
1632
|
+
// there: an app's thread lives inside the app panel, and a `dashboard` conversation is the
|
|
1633
|
+
// home composer itself.
|
|
1544
1634
|
const chat_finished_link = function (conversation_doc, conversation_id) {
|
|
1545
1635
|
const base = embed_origin();
|
|
1546
1636
|
const reference_type = conversation_doc?.reference_type;
|
|
1547
1637
|
const reference_id = conversation_doc?.reference_id;
|
|
1548
|
-
if (!reference_type || reference_type === 'ai_chats') return `${base}/dashboard/ai_chats/${conversation_id}`;
|
|
1549
1638
|
if (reference_type === 'dashboard') return `${base}/dashboard`;
|
|
1550
1639
|
if (reference_type === 'studio') return reference_id ? `${base}/dashboard/apps/${reference_id}` : `${base}/dashboard/apps`;
|
|
1551
|
-
if (
|
|
1640
|
+
if (conversation_id) return `${base}/dashboard/ai_chats/${conversation_id}`;
|
|
1641
|
+
if (!reference_type || !reference_id) return `${base}/dashboard`;
|
|
1552
1642
|
return `${base}/dashboard/${reference_type}/${reference_id}`;
|
|
1553
1643
|
};
|
|
1554
1644
|
|
|
@@ -1644,8 +1734,15 @@ const notify_chat_finished = async function ({ uid, conversation_id, conversatio
|
|
|
1644
1734
|
notification_msa.submit_notification?.({
|
|
1645
1735
|
type: 'ai',
|
|
1646
1736
|
uid_arr: [uid],
|
|
1647
|
-
|
|
1737
|
+
// UI-193: the NAME, and nothing else. Boaz, on a finished pitch-deck run: "keep only
|
|
1738
|
+
// the name in the fcm/toast". It used to lead with "Ready:" and then spend the body
|
|
1739
|
+
// on the first 160 characters of the answer, which on a lock screen is a paragraph of
|
|
1740
|
+
// half-sentences and a truncated URL. The picture says who it is from and the name
|
|
1741
|
+
// says which chat, so the text the notification shows is exactly the name.
|
|
1742
|
+
subject: title || 'Your chat is ready',
|
|
1743
|
+
// Kept for the bell, which is a list you read rather than a line you glance at.
|
|
1648
1744
|
body: chat_finished_summary(text),
|
|
1745
|
+
push_body: '',
|
|
1649
1746
|
// The chat's own picture, on the push and on the toast that stands in for it.
|
|
1650
1747
|
...(image ? { icon: image } : {}),
|
|
1651
1748
|
delivery_method: ['push'],
|
|
@@ -1662,6 +1759,98 @@ const notify_chat_finished = async function ({ uid, conversation_id, conversatio
|
|
|
1662
1759
|
}
|
|
1663
1760
|
};
|
|
1664
1761
|
|
|
1762
|
+
// ─── Message from a person ────────────────────────────────────────────────────
|
|
1763
|
+
// UI-193. A person-to-person chat only ever reached the recipient over the socket, as a
|
|
1764
|
+
// conversation_doc_updated the Chats screen redraws off. So a message arrived silently
|
|
1765
|
+
// unless that exact screen happened to be open: nothing on a phone, nothing on another
|
|
1766
|
+
// tab, nothing on any other screen in the dashboard. Same three roads as the chat-finished
|
|
1767
|
+
// alert (system notification, foreground toast, socket toast when there is no live token),
|
|
1768
|
+
// and the same rule about what it says: the sender's NAME and the sender's FACE, with the
|
|
1769
|
+
// message itself kept for the notification center.
|
|
1770
|
+
//
|
|
1771
|
+
// The picture is the one the RECIPIENT has of the sender, not the sender's own copy of
|
|
1772
|
+
// themselves: `connection_contact_id` is the mirror contact in the recipient's book, which
|
|
1773
|
+
// is what their Chats list and contact card already draw, so the alert and the thread it
|
|
1774
|
+
// opens show the same person.
|
|
1775
|
+
//
|
|
1776
|
+
// Who the sender IS, resolved the way the recipient would recognise them. Three roads,
|
|
1777
|
+
// because the first two can both come up empty:
|
|
1778
|
+
// 1. the mirror contact the connection request recorded (`connection_contact_id`),
|
|
1779
|
+
// 2. failing that, the recipient's own contact carrying the sender's uid. The mirror id
|
|
1780
|
+
// is only written for a `contact_connection` request, so a pair connected any other
|
|
1781
|
+
// way (an ai_agent share, for one) has a perfectly good contact on both sides and no
|
|
1782
|
+
// pointer between them. Without this the alert read "New message" with no face,
|
|
1783
|
+
// 3. failing that, the sender's account itself, so a message from someone not yet in
|
|
1784
|
+
// your book still says who it is from instead of going anonymous.
|
|
1785
|
+
const chat_message_sender = async function (to_uid, from_uid, from_contact_id) {
|
|
1786
|
+
let contact_id = from_contact_id;
|
|
1787
|
+
if (!contact_id && from_uid) {
|
|
1788
|
+
try {
|
|
1789
|
+
const to_app_id = await get_account_default_project_id(to_uid);
|
|
1790
|
+
const q = await db_module.find_app_couch_query(to_app_id, {
|
|
1791
|
+
selector: { docType: 'contact', contact_uid: from_uid },
|
|
1792
|
+
fields: ['_id'],
|
|
1793
|
+
limit: 1,
|
|
1794
|
+
});
|
|
1795
|
+
contact_id = q?.docs?.[0]?._id;
|
|
1796
|
+
} catch (err) {
|
|
1797
|
+
/* fall through to the account */
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
if (contact_id) {
|
|
1802
|
+
const contact = await get_contact_info(to_uid, null, contact_id).catch(() => null);
|
|
1803
|
+
const name = String(contact?.name || '').trim();
|
|
1804
|
+
const image = contact?.profile_picture || contact?.profile_avatar || undefined;
|
|
1805
|
+
if (name || image) return { name, image };
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
if (from_uid) {
|
|
1809
|
+
const acc = await get_account_name({ uid_query: from_uid }).catch(() => null);
|
|
1810
|
+
const d = acc?.data;
|
|
1811
|
+
if (d) {
|
|
1812
|
+
const name = (d.account_type === 'business' ? d.business_name : `${d.first_name || ''} ${d.last_name || ''}`.trim()) || '';
|
|
1813
|
+
return { name: String(name).trim(), image: d.profile_picture || d.profile_avatar || undefined };
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
return { name: '', image: undefined };
|
|
1818
|
+
};
|
|
1819
|
+
|
|
1820
|
+
const notify_chat_message = async function ({ to_uid, from_uid, from_contact_id, conversation_id, conversation_doc, text }) {
|
|
1821
|
+
try {
|
|
1822
|
+
if (!to_uid || !conversation_id) return;
|
|
1823
|
+
|
|
1824
|
+
// Not while they are looking at it. Same presence gate, and the same reading of a
|
|
1825
|
+
// non-answer: if ws_dashboard cannot say, stay quiet rather than interrupt someone
|
|
1826
|
+
// who is already reading the message.
|
|
1827
|
+
const presence = await Promise.race([
|
|
1828
|
+
ws_dashboard_ms.is_chat_open({ uid: to_uid, conversation_id }),
|
|
1829
|
+
new Promise((resolve) => setTimeout(() => resolve(null), CHAT_PRESENCE_TIMEOUT_MS)),
|
|
1830
|
+
]);
|
|
1831
|
+
if (!presence || presence.code < 0 || presence.data !== false) return;
|
|
1832
|
+
|
|
1833
|
+
const { name: from_name, image } = await chat_message_sender(to_uid, from_uid, from_contact_id);
|
|
1834
|
+
|
|
1835
|
+
notification_msa.submit_notification?.({
|
|
1836
|
+
type: 'chat',
|
|
1837
|
+
uid_arr: [to_uid],
|
|
1838
|
+
subject: from_name || 'New message',
|
|
1839
|
+
// The message, for the bell. `push_body` empties it on the alert itself.
|
|
1840
|
+
body: chat_finished_summary(text),
|
|
1841
|
+
push_body: '',
|
|
1842
|
+
...(image ? { icon: image } : {}),
|
|
1843
|
+
delivery_method: ['push'],
|
|
1844
|
+
display_type: 'info',
|
|
1845
|
+
ref: conversation_id,
|
|
1846
|
+
params: { kind: 'chat_message', conversation_id, reference_type: 'contacts' },
|
|
1847
|
+
link: chat_finished_link(conversation_doc, conversation_id),
|
|
1848
|
+
});
|
|
1849
|
+
} catch (err) {
|
|
1850
|
+
console.error(`[notify_chat_message] failed: ${err?.message || err}`);
|
|
1851
|
+
}
|
|
1852
|
+
};
|
|
1853
|
+
|
|
1665
1854
|
export const execute_codex_request = async function (req_or_ip, prompt_arg, attachments_arg = []) {
|
|
1666
1855
|
let emitToDashboard = function () {};
|
|
1667
1856
|
let streamText = function () {};
|
|
@@ -2649,6 +2838,10 @@ export const delete_ai_chat = async function (req) {
|
|
|
2649
2838
|
}
|
|
2650
2839
|
}
|
|
2651
2840
|
|
|
2841
|
+
// UI-202: the last row this chat gets. A shared chat is given back rather than deleted,
|
|
2842
|
+
// so the row says which of the two happened.
|
|
2843
|
+
log_chat_activity(uid, conversation_id, 'deleted', { by: 'user', note: conversation_doc.shared_from_uid ? 'the share was given back, so the chat left this account with it' : 'the messages, attachments and picture went with it' }, account_profile_info.app_id);
|
|
2844
|
+
|
|
2652
2845
|
return save_ret;
|
|
2653
2846
|
} catch (err) {
|
|
2654
2847
|
return { code: -3, data: err.message };
|
|
@@ -2673,6 +2866,9 @@ export const archive_ai_chat = async function (req) {
|
|
|
2673
2866
|
const save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, conversation_doc);
|
|
2674
2867
|
await update_conversation_items_stat(account_profile_info.app_id, 5, conversation_id);
|
|
2675
2868
|
|
|
2869
|
+
// UI-202
|
|
2870
|
+
log_chat_activity(uid, conversation_id, 'archived', { by: 'user' }, account_profile_info.app_id);
|
|
2871
|
+
|
|
2676
2872
|
return save_ret;
|
|
2677
2873
|
} catch (err) {
|
|
2678
2874
|
return { code: -3, data: err.message };
|
|
@@ -2696,6 +2892,9 @@ export const delete_conversation_item = async function (req) {
|
|
|
2696
2892
|
conversation_item_doc.stat = 4;
|
|
2697
2893
|
conversation_item_doc.ts = Date.now();
|
|
2698
2894
|
save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, conversation_item_doc);
|
|
2895
|
+
// UI-202: a message leaving a thread is the one edit to a chat that cannot be seen by
|
|
2896
|
+
// reading the thread afterwards, which makes it the row most worth having.
|
|
2897
|
+
log_chat_activity(uid, conversation_item_doc.conversation_id, 'message_deleted', { by: 'user', role: conversation_item_doc.role, preview: String(conversation_item_doc.text || '').replace(/\s+/g, ' ').slice(0, 80) }, account_profile_info.app_id);
|
|
2699
2898
|
const reference_conversation_id = conversation_doc?.reference_conversation_id || conversation_doc?.conversation_obj?.id;
|
|
2700
2899
|
const reference_item_id = conversation_item_doc?.conversation_item_reference_id;
|
|
2701
2900
|
const looksLikeConversationItemId = typeof reference_item_id === 'string' && /^(msg|item)_/.test(reference_item_id);
|
|
@@ -2735,6 +2934,9 @@ export const unarchive_ai_chat = async function (req) {
|
|
|
2735
2934
|
const save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, conversation_doc);
|
|
2736
2935
|
await update_conversation_items_stat(account_profile_info.app_id, 3, conversation_id);
|
|
2737
2936
|
|
|
2937
|
+
// UI-202
|
|
2938
|
+
log_chat_activity(uid, conversation_id, 'unarchived', { by: 'user' }, account_profile_info.app_id);
|
|
2939
|
+
|
|
2738
2940
|
return save_ret;
|
|
2739
2941
|
} catch (err) {
|
|
2740
2942
|
return { code: -3, data: err.message };
|
|
@@ -3225,6 +3427,46 @@ const get_user_ai_agents = async function (uid) {
|
|
|
3225
3427
|
return await db_module.find_app_couch_query(account_profile_info.app_id, opt);
|
|
3226
3428
|
};
|
|
3227
3429
|
|
|
3430
|
+
// UI-187: agent id -> the run currently in flight for it, as {conversation_id, stat, ts}.
|
|
3431
|
+
// A conversation is created at stat 1 (initiating), moves to 2 while the answer streams and
|
|
3432
|
+
// lands on 3 when it is done, so `stat < 3` IS "this agent is working". When an agent has
|
|
3433
|
+
// more than one live run (a chat and a scheduled job, say) the newest wins: the pill says
|
|
3434
|
+
// the agent is busy, not how many things it is doing.
|
|
3435
|
+
// Never throws. A card without its pill is a smaller failure than an agents list that 500s.
|
|
3436
|
+
// A run that never reached stat 3 is not necessarily still going: a conversation abandoned
|
|
3437
|
+
// mid-flight, or one whose process died, sits at stat 1 or 2 forever. dev has one that has
|
|
3438
|
+
// been "initiating" since 2026-08-02, and without this cap its agent would wear the pill for
|
|
3439
|
+
// the rest of time. Two hours is far past any real answer, including the long research runs.
|
|
3440
|
+
const LIVE_AGENT_CHAT_MAX_AGE_MS = 2 * 60 * 60 * 1000;
|
|
3441
|
+
|
|
3442
|
+
const get_live_agent_conversations = async function (app_id, uid) {
|
|
3443
|
+
const live = new Map();
|
|
3444
|
+
try {
|
|
3445
|
+
const ret = await db_module.find_app_couch_query(app_id, {
|
|
3446
|
+
selector: {
|
|
3447
|
+
docType: 'chat_conversation',
|
|
3448
|
+
uid,
|
|
3449
|
+
reference_type: 'ai_agents',
|
|
3450
|
+
stat: { $lt: 3 },
|
|
3451
|
+
},
|
|
3452
|
+
fields: ['_id', 'reference_id', 'stat', 'ts'],
|
|
3453
|
+
limit: 200,
|
|
3454
|
+
});
|
|
3455
|
+
|
|
3456
|
+
const now = Date.now();
|
|
3457
|
+
for (const doc of ret?.docs || []) {
|
|
3458
|
+
if (!doc.reference_id) continue;
|
|
3459
|
+
if (!doc.ts || now - doc.ts > LIVE_AGENT_CHAT_MAX_AGE_MS) continue;
|
|
3460
|
+
const prev = live.get(doc.reference_id);
|
|
3461
|
+
if (prev && (prev.ts || 0) >= (doc.ts || 0)) continue;
|
|
3462
|
+
live.set(doc.reference_id, { conversation_id: doc._id, stat: doc.stat, ts: doc.ts || 0 });
|
|
3463
|
+
}
|
|
3464
|
+
} catch (err) {
|
|
3465
|
+
console.error(`[get_live_agent_conversations] ${err?.message || err}`);
|
|
3466
|
+
}
|
|
3467
|
+
return live;
|
|
3468
|
+
};
|
|
3469
|
+
|
|
3228
3470
|
export const get_ai_agents = async function (req, job_id, headers) {
|
|
3229
3471
|
let { uid, _id, search, filter_type = 'all', limit, skip, agent_id, profile_id } = req;
|
|
3230
3472
|
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
@@ -3404,6 +3646,19 @@ export const get_ai_agents = async function (req, job_id, headers) {
|
|
|
3404
3646
|
docs.push(info_doc);
|
|
3405
3647
|
}
|
|
3406
3648
|
|
|
3649
|
+
// UI-187: which of these agents is answering RIGHT NOW, so the card carries the same
|
|
3650
|
+
// Initiating / Streaming pill the chat card does. The dashboard keeps the pill live off
|
|
3651
|
+
// `conversation_doc_updated` once the tab is open; this is what a page LOADED mid-run
|
|
3652
|
+
// needs, otherwise a reload silently drops the pill until the next stat change.
|
|
3653
|
+
// One query rather than one per agent: `stat < 3` is only ever true of a run in flight,
|
|
3654
|
+
// so the result set is a handful of docs even for a busy account, and it runs against the
|
|
3655
|
+
// account's own project db.
|
|
3656
|
+
const live_by_agent = await get_live_agent_conversations(account_profile_info.app_id, uid);
|
|
3657
|
+
for (const doc of docs) {
|
|
3658
|
+
const live = live_by_agent.get(doc._id);
|
|
3659
|
+
if (live) doc.live_chat = live;
|
|
3660
|
+
}
|
|
3661
|
+
|
|
3407
3662
|
return { code: 8, data: { docs: [...requests_from.docs, ...docs], total_docs: user_agents.total_docs + requests_from.total_docs } };
|
|
3408
3663
|
} catch (err) {
|
|
3409
3664
|
return { code: -8, data: err.message };
|
|
@@ -3451,6 +3706,17 @@ export const delete_ai_agent = async function (req) {
|
|
|
3451
3706
|
}
|
|
3452
3707
|
delete_depended_chats(uid, agent_id);
|
|
3453
3708
|
}
|
|
3709
|
+
|
|
3710
|
+
// UI-78: the last row this agent gets. A share is given back rather than deleted, so
|
|
3711
|
+
// the row says which of the two happened.
|
|
3712
|
+
log_agent_activity(
|
|
3713
|
+
uid,
|
|
3714
|
+
agent_id,
|
|
3715
|
+
'deleted',
|
|
3716
|
+
{ by: 'user', note: prog_doc.studio_meta.shared_from_uid ? 'the share was given back, so the agent left this account with it' : 'conversations attached to this agent were deleted with it' },
|
|
3717
|
+
account_profile_info.app_id
|
|
3718
|
+
);
|
|
3719
|
+
|
|
3454
3720
|
return save_ret;
|
|
3455
3721
|
} catch (err) {
|
|
3456
3722
|
return { code: -9, data: err.message };
|
|
@@ -3473,6 +3739,9 @@ export const uninstall_ai_agent = async function (req) {
|
|
|
3473
3739
|
|
|
3474
3740
|
const save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, agent_doc);
|
|
3475
3741
|
|
|
3742
|
+
// UI-78
|
|
3743
|
+
log_agent_activity(uid, agent_id, 'uninstalled', { by: 'user', marketplace_id: agent_doc.studio_meta.installed_marketplace_id }, account_profile_info.app_id);
|
|
3744
|
+
|
|
3476
3745
|
return save_ret;
|
|
3477
3746
|
} catch (err) {
|
|
3478
3747
|
return { code: -9, data: err.message };
|
|
@@ -3496,6 +3765,9 @@ export const unarchive_ai_agent = async function (req) {
|
|
|
3496
3765
|
const save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, agent_doc);
|
|
3497
3766
|
const updated_conversation_items = await update_conversation_stat(account_profile_info.app_id, agent_id, 3, conversation_id);
|
|
3498
3767
|
|
|
3768
|
+
// UI-78
|
|
3769
|
+
log_agent_activity(uid, agent_id, 'unarchived', { by: 'user' }, account_profile_info.app_id);
|
|
3770
|
+
|
|
3499
3771
|
return { code: 1, data: { save_ret, updated_conversation_items } };
|
|
3500
3772
|
} catch (err) {
|
|
3501
3773
|
return { code: -9, data: err.message };
|
|
@@ -3583,9 +3855,24 @@ export const generate_ai_agent_image = async function (req, job_id, headers) {
|
|
|
3583
3855
|
const agent_doc = await db_module.get_app_couch_doc_native(app_id, agent_id);
|
|
3584
3856
|
if (!agent_doc || !agent_doc._id) return { code: -404, data: 'agent not found' };
|
|
3585
3857
|
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3858
|
+
// UI-189: generation takes tens of seconds, so it reports through the same
|
|
3859
|
+
// studio_meta.prep state the create/update runner uses and the card shows the same
|
|
3860
|
+
// "Making a picture" progress instead of nothing happening until the picture appears.
|
|
3861
|
+
// UI-78
|
|
3862
|
+
log_agent_activity(uid, agent_id, 'image_requested', { by: 'user' }, app_id);
|
|
3863
|
+
|
|
3864
|
+
(async () => {
|
|
3865
|
+
await set_agent_prep(app_id, agent_id, { stat: 2, step: AGENT_PREP_STEPS.image, done: 0, total: 1, started_ts: Date.now() });
|
|
3866
|
+
try {
|
|
3867
|
+
await update_thumbnail('ai_agent', agent_doc, app_id, uid, job_id, headers, null, null, account_profile_info);
|
|
3868
|
+
log_agent_activity(uid, agent_id, 'image_ready', { by: 'ai', source: 'generated' }, app_id);
|
|
3869
|
+
} catch (err) {
|
|
3870
|
+
console.error('[generate_ai_agent_image]', agent_id, err?.message || err);
|
|
3871
|
+
await set_agent_prep(app_id, agent_id, { failed_step: 'image', failed_reason: String(err?.message || err).slice(0, 200) });
|
|
3872
|
+
log_agent_activity(uid, agent_id, 'image_failed', { by: 'ai', error: String(err?.message || err).slice(0, 200) }, app_id);
|
|
3873
|
+
}
|
|
3874
|
+
await set_agent_prep(app_id, agent_id, { stat: 3, step: null, done: 1, total: 1 });
|
|
3875
|
+
})();
|
|
3589
3876
|
|
|
3590
3877
|
return { code: 1, data: { agent_id, started: true } };
|
|
3591
3878
|
} catch (err) {
|
|
@@ -3610,6 +3897,9 @@ export const archive_ai_agent = async function (req) {
|
|
|
3610
3897
|
const save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, agent_doc);
|
|
3611
3898
|
const updated_conversation_items = await update_conversation_stat(account_profile_info.app_id, agent_id, 5, conversation_id);
|
|
3612
3899
|
|
|
3900
|
+
// UI-78
|
|
3901
|
+
log_agent_activity(uid, agent_id, 'archived', { by: 'user', conversations: updated_conversation_items ? 'the agent conversations were archived with it' : undefined }, account_profile_info.app_id);
|
|
3902
|
+
|
|
3613
3903
|
return { code: 1, data: { save_ret, updated_conversation_items } };
|
|
3614
3904
|
} catch (err) {
|
|
3615
3905
|
return { code: -9, data: err.message };
|
|
@@ -3735,6 +4025,10 @@ export const update_ai_agent = async function (req, job_id, headers) {
|
|
|
3735
4025
|
try {
|
|
3736
4026
|
let agent_doc = await await db_module.get_app_couch_doc_native(account_profile_info.app_id, agent_id);
|
|
3737
4027
|
|
|
4028
|
+
// UI-78: taken BEFORE the config is overwritten, so the trail can say what the edit
|
|
4029
|
+
// actually changed rather than "the agent was updated".
|
|
4030
|
+
const config_before = _.cloneDeep(agent_doc.agentConfig || {});
|
|
4031
|
+
|
|
3738
4032
|
agent_doc.ts = Date.now();
|
|
3739
4033
|
agent_doc.agentConfig = agentConfig;
|
|
3740
4034
|
|
|
@@ -3755,14 +4049,30 @@ export const update_ai_agent = async function (req, job_id, headers) {
|
|
|
3755
4049
|
|
|
3756
4050
|
const data = await db_module.save_app_couch_doc_native(account_profile_info.app_id, agent_doc);
|
|
3757
4051
|
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
4052
|
+
// UI-78: one row per edit, naming the fields that moved and their new values (long
|
|
4053
|
+
// free text travels as a length, see diff_agent_config). An edit that changed nothing,
|
|
4054
|
+
// which is what saving an untouched form is, records nothing: a trail of empty
|
|
4055
|
+
// "updated" rows is exactly what made the doc's own ts useless to read.
|
|
4056
|
+
const { changed, values } = diff_agent_config(config_before, agentConfig);
|
|
4057
|
+
if (changed.length) {
|
|
4058
|
+
log_agent_activity(uid, agent_id, 'updated', { by: 'user', changed, values, previous_name: name_changed ? config_before.agent_name : undefined }, account_profile_info.app_id);
|
|
4059
|
+
}
|
|
4060
|
+
|
|
4061
|
+
// UI-189: an edit does the same work a create does, minus whatever it did not change:
|
|
4062
|
+
// the picture is only regenerated on a rename, and attachments are only read when new
|
|
4063
|
+
// ones were added. Same detached, sequenced runner, same progress on the card.
|
|
4064
|
+
run_agent_preparation({
|
|
4065
|
+
agent_doc,
|
|
4066
|
+
app_id: account_profile_info.app_id,
|
|
4067
|
+
uid,
|
|
4068
|
+
job_id,
|
|
4069
|
+
headers,
|
|
4070
|
+
account_profile_info,
|
|
4071
|
+
agentConfig,
|
|
4072
|
+
with_image: name_changed,
|
|
4073
|
+
fields_changed: { name_changed, agent_user_guide_changed, agent_category_changed, agent_subcategory_changed, agent_instructions_changed },
|
|
4074
|
+
});
|
|
3764
4075
|
|
|
3765
|
-
upload_agent_files(uid, agent_doc);
|
|
3766
4076
|
return { code: 10, data };
|
|
3767
4077
|
} catch (err) {
|
|
3768
4078
|
return { code: -10, data: err.message };
|
|
@@ -4126,17 +4436,490 @@ const upload_agent_files = async function (uid, agent_doc) {
|
|
|
4126
4436
|
// // }
|
|
4127
4437
|
// };
|
|
4128
4438
|
|
|
4439
|
+
// UI-189: creating an agent is not finished when the save returns. Behind it run the
|
|
4440
|
+
// property pass, the picture generation (tens of seconds, and it costs credits) and, when
|
|
4441
|
+
// the agent was given attachments, a transcribe-and-upload pass per file. Until all of that
|
|
4442
|
+
// lands the agent exists but cannot answer properly: its knowledge is not in its vector
|
|
4443
|
+
// store yet and it has no picture. Boaz: "show progress indication and limit the
|
|
4444
|
+
// use/selections if it still proceesing".
|
|
4445
|
+
//
|
|
4446
|
+
// `studio_meta.prep` is that state, and it is deliberately NOT the doc's `stat`: the
|
|
4447
|
+
// programs checker already writes stat 2 to mean "this agent has check errors"
|
|
4448
|
+
// (controller_module), so overloading it would make the two indistinguishable.
|
|
4449
|
+
// { stat: 2 running | 3 done, step, done, total, started_ts, ts }
|
|
4450
|
+
// Every write goes through a fresh read of the doc so it cannot clobber whatever the
|
|
4451
|
+
// preparation task itself just saved, and any save puts the doc on the changes feed, which
|
|
4452
|
+
// is what pushes `ai_agent_updated` to the dashboard. Never throws: preparation state that
|
|
4453
|
+
// fails to record must not take the preparation down with it.
|
|
4454
|
+
// Short on purpose. These are rendered inside the card's footer strip, which is ~100px wide
|
|
4455
|
+
// on the narrowest agent card, so anything longer truncates to nothing useful ("Reading
|
|
4456
|
+
// att..."). The card's tooltip carries the step and its position in the run.
|
|
4457
|
+
const AGENT_PREP_STEPS = {
|
|
4458
|
+
properties: 'Setting up',
|
|
4459
|
+
files: 'Reading files',
|
|
4460
|
+
image: 'Making picture',
|
|
4461
|
+
};
|
|
4462
|
+
|
|
4463
|
+
const set_agent_prep = async function (app_id, agent_id, patch) {
|
|
4464
|
+
try {
|
|
4465
|
+
const doc = await db_module.get_app_couch_doc_native(app_id, agent_id);
|
|
4466
|
+
if (!doc?._id) return;
|
|
4467
|
+
doc.studio_meta = doc.studio_meta || {};
|
|
4468
|
+
doc.studio_meta.prep = { ...(doc.studio_meta.prep || {}), ...patch, ts: Date.now() };
|
|
4469
|
+
doc.ts = Date.now();
|
|
4470
|
+
await db_module.save_app_couch_doc_native(app_id, doc);
|
|
4471
|
+
} catch (err) {
|
|
4472
|
+
console.error(`[set_agent_prep] ${agent_id}: ${err?.message || err}`);
|
|
4473
|
+
}
|
|
4474
|
+
};
|
|
4475
|
+
|
|
4476
|
+
// Does this agent have attachments still to be read? A tool whose file already carries a
|
|
4477
|
+
// file_id was uploaded on an earlier save, so re-saving an agent nobody changed the files of
|
|
4478
|
+
// must not claim to be reading them again.
|
|
4479
|
+
const agent_has_pending_files = function (agentConfig) {
|
|
4480
|
+
return (agentConfig?.agent_tools || []).some((tool) => {
|
|
4481
|
+
if (tool?.type !== 'file_search') return false;
|
|
4482
|
+
if (!_.isEmpty(tool.file) && !tool.file.file_id) return true;
|
|
4483
|
+
if (!_.isEmpty(tool.youtube) && !tool.youtube.file_id) return true;
|
|
4484
|
+
return false;
|
|
4485
|
+
});
|
|
4486
|
+
};
|
|
4487
|
+
|
|
4488
|
+
// The one place the post-save work runs, for both create and update. Sequenced rather than
|
|
4489
|
+
// fired off in parallel the way it used to be: two chains each doing get-modify-save on the
|
|
4490
|
+
// same doc raced (the picture could land on a revision that predated the uploaded file ids
|
|
4491
|
+
// and drop them), and a progress indicator can only be honest if it knows what is running.
|
|
4492
|
+
// Detached on purpose, the caller returns as soon as the agent exists.
|
|
4493
|
+
const run_agent_preparation = async function ({ agent_doc, app_id, uid, job_id, headers, account_profile_info, agentConfig, with_image, fields_changed }) {
|
|
4494
|
+
const tasks = ['properties'];
|
|
4495
|
+
if (agent_has_pending_files(agentConfig)) tasks.push('files');
|
|
4496
|
+
if (with_image) tasks.push('image');
|
|
4497
|
+
|
|
4498
|
+
let done = 0;
|
|
4499
|
+
await set_agent_prep(app_id, agent_doc._id, { stat: 2, step: AGENT_PREP_STEPS[tasks[0]], done, total: tasks.length, started_ts: Date.now() });
|
|
4500
|
+
|
|
4501
|
+
for (const task of tasks) {
|
|
4502
|
+
await set_agent_prep(app_id, agent_doc._id, { stat: 2, step: AGENT_PREP_STEPS[task], done, total: tasks.length });
|
|
4503
|
+
try {
|
|
4504
|
+
// The properties pass records its own row (it is the only place that knows WHICH
|
|
4505
|
+
// fields the AI actually filled in), see update_ai_agent_properties.
|
|
4506
|
+
if (task === 'properties') await update_ai_agent_properties(agent_doc, app_id, uid, fields_changed || {});
|
|
4507
|
+
if (task === 'files') {
|
|
4508
|
+
await upload_agent_files(uid, agent_doc);
|
|
4509
|
+
log_agent_activity(uid, agent_doc._id, 'files_read', { by: 'ai', count: (agentConfig?.agent_tools || []).filter((t) => t?.type === 'file_search').length }, app_id);
|
|
4510
|
+
}
|
|
4511
|
+
if (task === 'image') {
|
|
4512
|
+
await update_thumbnail('ai_agent', agent_doc, app_id, uid, job_id, headers, null, null, account_profile_info);
|
|
4513
|
+
log_agent_activity(uid, agent_doc._id, 'image_ready', { by: 'ai', source: 'generated' }, app_id);
|
|
4514
|
+
}
|
|
4515
|
+
} catch (err) {
|
|
4516
|
+
// One step failing does not strand the agent in "preparing" forever. The rest still
|
|
4517
|
+
// run and the agent opens for use; what failed is recorded on the doc.
|
|
4518
|
+
console.error(`[run_agent_preparation] ${agent_doc._id} ${task}: ${err?.message || err}`);
|
|
4519
|
+
await set_agent_prep(app_id, agent_doc._id, { failed_step: task, failed_reason: String(err?.message || err).slice(0, 200) });
|
|
4520
|
+
// UI-78: and on the trail, which is the only place it survives the next successful
|
|
4521
|
+
// run (set_agent_prep is overwritten, the trail is appended to).
|
|
4522
|
+
log_agent_activity(uid, agent_doc._id, 'preparation_failed', { step: AGENT_PREP_STEPS[task] || task, error: String(err?.message || err).slice(0, 200) }, app_id);
|
|
4523
|
+
}
|
|
4524
|
+
done += 1;
|
|
4525
|
+
}
|
|
4526
|
+
|
|
4527
|
+
await set_agent_prep(app_id, agent_doc._id, { stat: 3, step: null, done, total: tasks.length });
|
|
4528
|
+
};
|
|
4529
|
+
|
|
4530
|
+
// ─── UI-78: the AI agent activity trail ──────────────────────────────────────────────
|
|
4531
|
+
// Same idea as the contact trail (UI-134, account_module) and deliberately the same shape,
|
|
4532
|
+
// because it is read by the same panel: the agent doc keeps the CURRENT answer to every
|
|
4533
|
+
// question and nothing about how it got there, so "who changed the instructions", "when did
|
|
4534
|
+
// this get its picture", "why is this agent public" had no trace to read back.
|
|
4535
|
+
//
|
|
4536
|
+
// One `agent_activity` doc per event, in the same app db as the agent, keyed on agent_id.
|
|
4537
|
+
// Append-only and deliberately silent: failing to WRITE the trail must never fail the action
|
|
4538
|
+
// being recorded, so every call is wrapped and logged to the console instead of thrown, and
|
|
4539
|
+
// callers do not await it unless they were already awaiting something else on the same line.
|
|
4540
|
+
//
|
|
4541
|
+
// A SHARED or INSTALLED agent is a copy in the receiver's own app db, so each owner gets
|
|
4542
|
+
// their own trail. That is the honest reading: what the sender did to their agent before it
|
|
4543
|
+
// was shared is the sender's history, not the receiver's.
|
|
4544
|
+
const log_agent_activity = async function (uid, agent_id, event, detail = {}, app_id) {
|
|
4545
|
+
try {
|
|
4546
|
+
if (!uid || !agent_id || !event) return null;
|
|
4547
|
+
const app = app_id || (await get_active_account_profile_info(uid))?.app_id;
|
|
4548
|
+
if (!app) return null;
|
|
4549
|
+
|
|
4550
|
+
return await db_module.save_app_couch_doc_native(app, {
|
|
4551
|
+
_id: await _common.xuda_get_uuid('agent_activity'),
|
|
4552
|
+
docType: 'agent_activity',
|
|
4553
|
+
agent_id,
|
|
4554
|
+
uid,
|
|
4555
|
+
event,
|
|
4556
|
+
// Values the UI prints back verbatim, so keep them short and human. Never put a full
|
|
4557
|
+
// instruction set or a tool payload here: the trail is a summary, not a backup.
|
|
4558
|
+
detail,
|
|
4559
|
+
ts: Date.now(),
|
|
4560
|
+
stat: 3,
|
|
4561
|
+
});
|
|
4562
|
+
} catch (err) {
|
|
4563
|
+
console.error('[ai_module] agent activity not recorded:', event, agent_id, err?.message || err);
|
|
4564
|
+
return null;
|
|
4565
|
+
}
|
|
4566
|
+
};
|
|
4567
|
+
|
|
4568
|
+
// The cross-module door onto the same helper, for the places that act on an agent from
|
|
4569
|
+
// outside ai_module (team_module shares one, marketplace_module installs one). Fire and
|
|
4570
|
+
// forget through index_msa, never awaited by those callers.
|
|
4571
|
+
export const log_ai_agent_activity = async function (req) {
|
|
4572
|
+
const { uid, agent_id, event, detail, app_id } = req || {};
|
|
4573
|
+
const ret = await log_agent_activity(uid, agent_id, event, detail || {}, app_id);
|
|
4574
|
+
return { code: ret ? 1 : 0, data: ret ? { agent_id, event } : 'not recorded' };
|
|
4575
|
+
};
|
|
4576
|
+
|
|
4577
|
+
// What an edit actually changed, as a list of field names the UI can print. agentConfig is
|
|
4578
|
+
// the whole form the editor posts back, so a naive "the config changed" row says nothing:
|
|
4579
|
+
// this compares it field by field against what was stored and keeps the ones that moved.
|
|
4580
|
+
// Instructions and the user guide are long free text, so only their LENGTH travels; the
|
|
4581
|
+
// point of the row is that they changed and by roughly how much, not to mirror them.
|
|
4582
|
+
const AGENT_CONFIG_FIELD_LABELS = {
|
|
4583
|
+
agent_name: 'name',
|
|
4584
|
+
agent_instructions: 'instructions',
|
|
4585
|
+
agent_user_guide: 'user guide',
|
|
4586
|
+
agent_category: 'category',
|
|
4587
|
+
agent_subcategory: 'subcategory',
|
|
4588
|
+
agent_industry: 'industry',
|
|
4589
|
+
agent_tags: 'tags',
|
|
4590
|
+
agent_ai_model: 'model',
|
|
4591
|
+
agent_visibility: 'visibility',
|
|
4592
|
+
agent_price: 'price',
|
|
4593
|
+
agent_tools: 'tools',
|
|
4594
|
+
agent_marketplace_image: 'marketplace image',
|
|
4595
|
+
};
|
|
4596
|
+
|
|
4597
|
+
const diff_agent_config = function (before = {}, after = {}) {
|
|
4598
|
+
const changed = [];
|
|
4599
|
+
const values = {};
|
|
4600
|
+
|
|
4601
|
+
for (const key of Object.keys(AGENT_CONFIG_FIELD_LABELS)) {
|
|
4602
|
+
const a = before?.[key];
|
|
4603
|
+
const b = after?.[key];
|
|
4604
|
+
if (_.isEqual(a ?? null, b ?? null)) continue;
|
|
4605
|
+
changed.push(AGENT_CONFIG_FIELD_LABELS[key]);
|
|
4606
|
+
|
|
4607
|
+
switch (key) {
|
|
4608
|
+
case 'agent_instructions':
|
|
4609
|
+
case 'agent_user_guide':
|
|
4610
|
+
values[AGENT_CONFIG_FIELD_LABELS[key]] = `${String(a || '').length} to ${String(b || '').length} characters`;
|
|
4611
|
+
break;
|
|
4612
|
+
case 'agent_tools':
|
|
4613
|
+
values.tools = `${(a || []).length} to ${(b || []).length}`;
|
|
4614
|
+
break;
|
|
4615
|
+
case 'agent_tags':
|
|
4616
|
+
values.tags = (b || []).join(', ').slice(0, 120);
|
|
4617
|
+
break;
|
|
4618
|
+
default:
|
|
4619
|
+
values[AGENT_CONFIG_FIELD_LABELS[key]] = String(b ?? '').slice(0, 120);
|
|
4620
|
+
break;
|
|
4621
|
+
}
|
|
4622
|
+
}
|
|
4623
|
+
|
|
4624
|
+
return { changed, values };
|
|
4625
|
+
};
|
|
4626
|
+
|
|
4627
|
+
// UI-208: an agent edited in STUDIO, which is the one writer that never reaches this module.
|
|
4628
|
+
// The Studio client saves its docs straight into CouchDB with no CPI hop, so the only
|
|
4629
|
+
// server-side witness is controller_module's changes reader, which sees every revision of
|
|
4630
|
+
// every studio doc whoever wrote it. That is also why this cannot simply log what it sees:
|
|
4631
|
+
// the same reader watches the revisions ai_module itself produces (the preparation runner
|
|
4632
|
+
// alone saves several times per create), and a row per revision would bury the real edits.
|
|
4633
|
+
//
|
|
4634
|
+
// Two gates, in cost order. First, did the AGENT actually change? Compared against the
|
|
4635
|
+
// previous revision out of the version history the same reader captured a line earlier, so
|
|
4636
|
+
// a save that only moved preparation state, a picture or a stat writes nothing. Second, did
|
|
4637
|
+
// a server path already narrate this edit? Any activity row within the quiet window means
|
|
4638
|
+
// yes (update_ai_agent logs before the reader gets there), so only a write that came from
|
|
4639
|
+
// outside this module survives to be recorded.
|
|
4640
|
+
const STUDIO_EDIT_QUIET_MS = 30 * 1000;
|
|
4641
|
+
|
|
4642
|
+
export const record_studio_agent_edit = async function (req) {
|
|
4643
|
+
const { app_id, doc } = req || {};
|
|
4644
|
+
try {
|
|
4645
|
+
if (!app_id || !doc?._id) return { code: 0, data: 'nothing to record' };
|
|
4646
|
+
if (doc.docType !== 'studio' || doc?.properties?.menuType !== 'ai_agent') return { code: 0, data: 'not an agent' };
|
|
4647
|
+
const uid = doc?.studio_meta?.createdByUid || doc.uid;
|
|
4648
|
+
if (!uid) return { code: 0, data: 'no owner on the doc' };
|
|
4649
|
+
|
|
4650
|
+
const prev_ret = await db_module.get_studio_doc_previous_version(app_id, doc._id, doc.ts || Date.now());
|
|
4651
|
+
const prev = prev_ret?.code > 0 ? prev_ret.data : null;
|
|
4652
|
+
// No earlier snapshot means this is the first revision history ever saw, and there is
|
|
4653
|
+
// nothing honest to say about what changed.
|
|
4654
|
+
if (!prev) return { code: 0, data: 'no previous revision to compare' };
|
|
4655
|
+
|
|
4656
|
+
const { changed, values } = diff_agent_config(prev.agentConfig || {}, doc.agentConfig || {});
|
|
4657
|
+
// The name lives on properties, not agentConfig, and renaming in Studio is exactly the
|
|
4658
|
+
// kind of edit somebody later goes looking for.
|
|
4659
|
+
const prev_name = prev?.properties?.menuName;
|
|
4660
|
+
const next_name = doc?.properties?.menuName;
|
|
4661
|
+
if (prev_name !== next_name) {
|
|
4662
|
+
changed.unshift('name');
|
|
4663
|
+
values.name = String(next_name ?? '').slice(0, 120);
|
|
4664
|
+
}
|
|
4665
|
+
if (!changed.length) return { code: 0, data: 'the agent itself did not change' };
|
|
4666
|
+
|
|
4667
|
+
const recent = await db_module.find_app_couch_query(app_id, {
|
|
4668
|
+
selector: { docType: 'agent_activity', agent_id: doc._id },
|
|
4669
|
+
limit: 200,
|
|
4670
|
+
});
|
|
4671
|
+
const cutoff = (doc.ts || Date.now()) - STUDIO_EDIT_QUIET_MS;
|
|
4672
|
+
if ((recent?.docs || []).some((row) => (row.ts || 0) >= cutoff)) return { code: 0, data: 'already recorded by the path that made the change' };
|
|
4673
|
+
|
|
4674
|
+
await log_agent_activity(uid, doc._id, 'updated', { changed, values, previous_name: prev_name !== next_name ? prev_name : undefined, source: 'edited outside the dashboard' }, app_id);
|
|
4675
|
+
return { code: 1, data: { agent_id: doc._id, changed } };
|
|
4676
|
+
} catch (err) {
|
|
4677
|
+
console.error('[record_studio_agent_edit]', doc?._id, err?.message || err);
|
|
4678
|
+
return { code: -1, data: err?.message || String(err) };
|
|
4679
|
+
}
|
|
4680
|
+
};
|
|
4681
|
+
|
|
4682
|
+
// Reads the trail for one agent, newest first, exactly the way get_contact_activity does:
|
|
4683
|
+
// rows the server RECORDED, plus rows reconstructed from the agent doc for everything that
|
|
4684
|
+
// happened before the trail existed (which is every agent that already exists today). A
|
|
4685
|
+
// derived row carries the closest honest timestamp the doc has, not the real one, and says
|
|
4686
|
+
// so, so the panel can mark it as reconstructed rather than observed.
|
|
4687
|
+
export const get_ai_agent_activity = async function (req) {
|
|
4688
|
+
const { uid, agent_id, profile_id } = req;
|
|
4689
|
+
try {
|
|
4690
|
+
if (!agent_id) throw new Error('agent_id is missing');
|
|
4691
|
+
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
4692
|
+
|
|
4693
|
+
// get_app_couch_doc_native throws a bare couch 'missing' for an id that is not in this
|
|
4694
|
+
// account's app db, which is how an id from another account arrives here. Catch it and
|
|
4695
|
+
// answer the question that was actually asked.
|
|
4696
|
+
let agent_doc;
|
|
4697
|
+
try {
|
|
4698
|
+
agent_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, agent_id);
|
|
4699
|
+
} catch (_) {
|
|
4700
|
+
agent_doc = null;
|
|
4701
|
+
}
|
|
4702
|
+
if (!agent_doc || agent_doc.docType !== 'studio' || agent_doc?.properties?.menuType !== 'ai_agent') throw new Error(`agent ${agent_id} not found`);
|
|
4703
|
+
|
|
4704
|
+
const recorded_ret = await db_module.find_app_couch_query(account_profile_info.app_id, {
|
|
4705
|
+
selector: { docType: 'agent_activity', agent_id },
|
|
4706
|
+
limit: 500,
|
|
4707
|
+
});
|
|
4708
|
+
const recorded = (recorded_ret?.docs || []).map((d) => ({ event: d.event, detail: d.detail || {}, ts: d.ts, derived: false }));
|
|
4709
|
+
|
|
4710
|
+
const derived = [];
|
|
4711
|
+
const add = (event, ts, detail) => {
|
|
4712
|
+
if (!ts) return;
|
|
4713
|
+
// A recorded row always wins: it has the real timestamp and the real inputs.
|
|
4714
|
+
if (recorded.some((r) => r.event === event)) return;
|
|
4715
|
+
derived.push({ event, detail, ts, derived: true });
|
|
4716
|
+
};
|
|
4717
|
+
|
|
4718
|
+
const meta = agent_doc.studio_meta || {};
|
|
4719
|
+
const config = agent_doc.agentConfig || {};
|
|
4720
|
+
const created_ts = agent_doc.date_created_ts || meta.date_created_ts || agent_doc.ts;
|
|
4721
|
+
|
|
4722
|
+
add('created', created_ts, { name: agent_doc?.properties?.menuName, model: config.agent_ai_model, tools: (config.agent_tools || []).length, visibility: config.agent_visibility });
|
|
4723
|
+
if (meta.shared_from_uid) add('shared_with_you', meta.shared_ts || created_ts, { from_uid: meta.shared_from_uid, access_type: meta.shared_access_type });
|
|
4724
|
+
if (meta.installed_from_app_id) add('installed', meta.installed_ts || created_ts, { marketplace_id: meta.installed_marketplace_id });
|
|
4725
|
+
if (meta.agent_assistant_name || config.agent_category || config.agent_industry) {
|
|
4726
|
+
add('properties_filled', meta.thumbnail_request_ts || created_ts, { assistant_name: meta.agent_assistant_name, category: config.agent_category, industry: config.agent_industry });
|
|
4727
|
+
}
|
|
4728
|
+
if (meta.agent_image?.length) add('image_ready', meta.thumbnail_request_ts || created_ts, { source: 'generated' });
|
|
4729
|
+
if (meta.prep?.failed_step) add('preparation_failed', meta.prep?.ts || created_ts, { step: meta.prep.failed_step, error: meta.prep.failed_reason });
|
|
4730
|
+
if (meta.pinned) add('pinned', agent_doc.ts || created_ts, {});
|
|
4731
|
+
// stat carries only the LAST transition, which is exactly what the recorded trail adds
|
|
4732
|
+
// from here on: an archive / unarchive history the doc itself cannot hold.
|
|
4733
|
+
if (agent_doc.stat === 5) add('archived', agent_doc.ts, {});
|
|
4734
|
+
if (agent_doc.stat === 4) add('deleted', agent_doc.ts, {});
|
|
4735
|
+
|
|
4736
|
+
// The marketplace listing is the one part of an agent's story that does NOT live in the
|
|
4737
|
+
// app db: publishing writes a marketplace_ai_agent doc in xuda_marketplace, and the
|
|
4738
|
+
// publish gate's verdict lands there too. It carries a real publish_stat_ts, so unlike
|
|
4739
|
+
// most reconstructed rows this one has an honest time. Never fatal: an agent that was
|
|
4740
|
+
// never published has no listing, and a marketplace read that fails is a missing row,
|
|
4741
|
+
// not a failed trail.
|
|
4742
|
+
//
|
|
4743
|
+
// Only for an agent this account actually OWNS. A marketplace install keeps the source
|
|
4744
|
+
// agent's _id, so the listing would be found from the installed copy as well and every
|
|
4745
|
+
// installer would be told they published it. The installed copy already says where it
|
|
4746
|
+
// came from, which is the true thing to say about it.
|
|
4747
|
+
let listing = null;
|
|
4748
|
+
if (!meta.installed_from_app_id && !meta.shared_from_uid) {
|
|
4749
|
+
try {
|
|
4750
|
+
const listing_ret = await db_module.find_couch_query('xuda_marketplace', {
|
|
4751
|
+
selector: { docType: 'marketplace_ai_agent', prog_id: agent_id },
|
|
4752
|
+
limit: 1,
|
|
4753
|
+
});
|
|
4754
|
+
const found = listing_ret?.docs?.[0] || null;
|
|
4755
|
+
// Second guard for the same reason: the listing belongs to whoever published it.
|
|
4756
|
+
if (found && (!found.app_uid || found.app_uid === uid)) listing = found;
|
|
4757
|
+
} catch (err) {
|
|
4758
|
+
console.error('[get_ai_agent_activity] marketplace listing not read:', agent_id, err?.message || err);
|
|
4759
|
+
}
|
|
4760
|
+
}
|
|
4761
|
+
if (listing) {
|
|
4762
|
+
const listing_ts = listing.publish_stat_ts || listing.stat_ts || listing.ts;
|
|
4763
|
+
if (listing.stat === 3) add('published', listing_ts, { category: listing.agent_category, price: listing.price, approved_by: listing.approval?.reviewed_by });
|
|
4764
|
+
if (listing.stat === 6) add('publish_rejected', listing_ts, { reason: listing.publish_reason || (listing.approval?.reasons || []).join('; ') });
|
|
4765
|
+
if (listing.stat === 1 && listing.approval?.status === 'pending') add('publish_review', listing_ts, { reason: listing.publish_reason || (listing.approval?.reasons || []).join('; ') });
|
|
4766
|
+
}
|
|
4767
|
+
|
|
4768
|
+
const rows = [...recorded, ...derived].sort((a, b) => (b.ts || 0) - (a.ts || 0));
|
|
4769
|
+
|
|
4770
|
+
return {
|
|
4771
|
+
code: 1,
|
|
4772
|
+
data: {
|
|
4773
|
+
agent_id,
|
|
4774
|
+
// What the card shows today, so the panel can head the trail with the outcome.
|
|
4775
|
+
current: {
|
|
4776
|
+
name: agent_doc?.properties?.menuName || null,
|
|
4777
|
+
stat: agent_doc.stat,
|
|
4778
|
+
pinned: !!meta.pinned,
|
|
4779
|
+
visibility: config.agent_visibility || null,
|
|
4780
|
+
model: config.agent_ai_model || null,
|
|
4781
|
+
tools: (config.agent_tools || []).length,
|
|
4782
|
+
shared: !!meta.shared_from_uid,
|
|
4783
|
+
installed: !!meta.installed_from_app_id,
|
|
4784
|
+
// null when the agent was never published, so the panel can tell "private" from
|
|
4785
|
+
// "listed and live" from "held by the publish gate".
|
|
4786
|
+
marketplace_stat: listing ? listing.stat : null,
|
|
4787
|
+
},
|
|
4788
|
+
rows,
|
|
4789
|
+
},
|
|
4790
|
+
};
|
|
4791
|
+
} catch (err) {
|
|
4792
|
+
return { code: -25, data: err.message };
|
|
4793
|
+
}
|
|
4794
|
+
};
|
|
4795
|
+
|
|
4796
|
+
// ─── UI-202: the chat activity trail ──────────────────────────────────────────────────
|
|
4797
|
+
// The third of the same family (contacts UI-134, agents UI-210), and deliberately identical
|
|
4798
|
+
// in shape because one panel renders all of them. A conversation doc keeps the CURRENT
|
|
4799
|
+
// title, category, picture and mood and nothing about when any of them were decided, so
|
|
4800
|
+
// "why is this chat called that", "when was it scored red" and "who archived it" had no
|
|
4801
|
+
// trace. Message-by-message content is NOT in here: the thread itself is that record. This
|
|
4802
|
+
// is what happened TO the chat.
|
|
4803
|
+
const log_chat_activity = async function (uid, conversation_id, event, detail = {}, app_id) {
|
|
4804
|
+
try {
|
|
4805
|
+
if (!uid || !conversation_id || !event) return null;
|
|
4806
|
+
const app = app_id || (await get_active_account_profile_info(uid))?.app_id;
|
|
4807
|
+
if (!app) return null;
|
|
4808
|
+
|
|
4809
|
+
return await db_module.save_app_couch_doc_native(app, {
|
|
4810
|
+
_id: await _common.xuda_get_uuid('chat_activity'),
|
|
4811
|
+
docType: 'chat_activity',
|
|
4812
|
+
conversation_id,
|
|
4813
|
+
uid,
|
|
4814
|
+
event,
|
|
4815
|
+
detail,
|
|
4816
|
+
ts: Date.now(),
|
|
4817
|
+
stat: 3,
|
|
4818
|
+
});
|
|
4819
|
+
} catch (err) {
|
|
4820
|
+
console.error('[ai_module] chat activity not recorded:', event, conversation_id, err?.message || err);
|
|
4821
|
+
return null;
|
|
4822
|
+
}
|
|
4823
|
+
};
|
|
4824
|
+
|
|
4825
|
+
// The cross-module door, for team_module when a chat is shared.
|
|
4826
|
+
export const log_ai_chat_activity = async function (req) {
|
|
4827
|
+
const { uid, conversation_id, event, detail, app_id } = req || {};
|
|
4828
|
+
const ret = await log_chat_activity(uid, conversation_id, event, detail || {}, app_id);
|
|
4829
|
+
return { code: ret ? 1 : 0, data: ret ? { conversation_id, event } : 'not recorded' };
|
|
4830
|
+
};
|
|
4831
|
+
|
|
4832
|
+
export const get_ai_chat_activity = async function (req) {
|
|
4833
|
+
const { uid, conversation_id, profile_id } = req;
|
|
4834
|
+
try {
|
|
4835
|
+
if (!conversation_id) throw new Error('conversation_id is missing');
|
|
4836
|
+
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
4837
|
+
|
|
4838
|
+
let conversation_doc;
|
|
4839
|
+
try {
|
|
4840
|
+
conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_id);
|
|
4841
|
+
} catch (_) {
|
|
4842
|
+
conversation_doc = null;
|
|
4843
|
+
}
|
|
4844
|
+
if (!conversation_doc || conversation_doc.docType !== 'chat_conversation') throw new Error(`chat ${conversation_id} not found`);
|
|
4845
|
+
if (conversation_doc.uid !== uid && conversation_doc?.account_profile_info?.uid !== uid && conversation_doc?.initiator_uid !== uid) throw new Error('Operation not allowed');
|
|
4846
|
+
|
|
4847
|
+
const recorded_ret = await db_module.find_app_couch_query(account_profile_info.app_id, {
|
|
4848
|
+
selector: { docType: 'chat_activity', conversation_id },
|
|
4849
|
+
limit: 500,
|
|
4850
|
+
});
|
|
4851
|
+
const recorded = (recorded_ret?.docs || []).map((d) => ({ event: d.event, detail: d.detail || {}, ts: d.ts, derived: false }));
|
|
4852
|
+
|
|
4853
|
+
const derived = [];
|
|
4854
|
+
const add = (event, ts, detail) => {
|
|
4855
|
+
if (!ts) return;
|
|
4856
|
+
if (recorded.some((r) => r.event === event)) return;
|
|
4857
|
+
derived.push({ event, detail, ts, derived: true });
|
|
4858
|
+
};
|
|
4859
|
+
|
|
4860
|
+
const created_ts = conversation_doc.date_created_ts || conversation_doc.ts;
|
|
4861
|
+
add('created', created_ts, {
|
|
4862
|
+
type: conversation_doc.conversation_type,
|
|
4863
|
+
model: conversation_doc.model,
|
|
4864
|
+
reference_type: conversation_doc.reference_type,
|
|
4865
|
+
plan_mode: !!conversation_doc.plan_mode,
|
|
4866
|
+
source: conversation_doc.source,
|
|
4867
|
+
});
|
|
4868
|
+
// The title only ever differs from the opening words when the AI named it, which is the
|
|
4869
|
+
// whole reason the row is worth having.
|
|
4870
|
+
if (conversation_doc.title && conversation_doc.prompt && conversation_doc.title !== getFirstNWords(conversation_doc.prompt, 10)) {
|
|
4871
|
+
add('title_set', conversation_doc.ts || created_ts, { by: 'ai', title: conversation_doc.title });
|
|
4872
|
+
}
|
|
4873
|
+
if (conversation_doc?.category_info?.category) add('categorized', conversation_doc.ts || created_ts, { by: 'ai', category: conversation_doc.category_info.category });
|
|
4874
|
+
if (conversation_doc?.chat_image?.length) add('image_ready', conversation_doc.thumbnail_request_ts || created_ts, { by: 'ai' });
|
|
4875
|
+
if (typeof conversation_doc.mood_level === 'number') add('mood_scored', conversation_doc.ts || created_ts, { by: 'ai', mood_level: conversation_doc.mood_level });
|
|
4876
|
+
if (conversation_doc.shared_from_uid) add('shared_with_you', conversation_doc.shared_ts || created_ts, { from_uid: conversation_doc.shared_from_uid });
|
|
4877
|
+
if (conversation_doc.pinned) add('pinned', conversation_doc.ts || created_ts, {});
|
|
4878
|
+
if (conversation_doc.stat === 5) add('archived', conversation_doc.ts, {});
|
|
4879
|
+
if (conversation_doc.stat === 4) add('deleted', conversation_doc.ts, {});
|
|
4880
|
+
|
|
4881
|
+
const rows = [...recorded, ...derived].sort((a, b) => (b.ts || 0) - (a.ts || 0));
|
|
4882
|
+
|
|
4883
|
+
return {
|
|
4884
|
+
code: 1,
|
|
4885
|
+
data: {
|
|
4886
|
+
conversation_id,
|
|
4887
|
+
current: {
|
|
4888
|
+
title: conversation_doc.title || null,
|
|
4889
|
+
stat: conversation_doc.stat,
|
|
4890
|
+
pinned: !!conversation_doc.pinned,
|
|
4891
|
+
type: conversation_doc.conversation_type || null,
|
|
4892
|
+
model: conversation_doc.model || null,
|
|
4893
|
+
category: conversation_doc?.category_info?.category || null,
|
|
4894
|
+
mood_level: typeof conversation_doc.mood_level === 'number' ? conversation_doc.mood_level : null,
|
|
4895
|
+
shared: !!conversation_doc.shared_from_uid,
|
|
4896
|
+
},
|
|
4897
|
+
rows,
|
|
4898
|
+
},
|
|
4899
|
+
};
|
|
4900
|
+
} catch (err) {
|
|
4901
|
+
return { code: -25, data: err.message };
|
|
4902
|
+
}
|
|
4903
|
+
};
|
|
4904
|
+
|
|
4129
4905
|
const save_agent_status = async function (uid, agent_id, stat, agentConfig) {
|
|
4130
4906
|
// const project_db = await get_account_project_db(uid);
|
|
4131
4907
|
const account_profile_info = await get_active_account_profile_info(uid);
|
|
4132
4908
|
|
|
4133
4909
|
try {
|
|
4134
4910
|
let agent_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, agent_id);
|
|
4911
|
+
// UI-204: this rewrites the whole agentConfig from an AI build pass, so it is a real
|
|
4912
|
+
// edit and the trail has to say so. Same diff as the dashboard's own edit, so an agent
|
|
4913
|
+
// the AI rewrote reads exactly like one a person rewrote, minus who did it.
|
|
4914
|
+
const config_before = _.cloneDeep(agent_doc.agentConfig || {});
|
|
4135
4915
|
agent_doc.ts = Date.now();
|
|
4136
4916
|
agent_doc.stat = stat;
|
|
4137
4917
|
agent_doc.agentConfig = agentConfig;
|
|
4138
4918
|
const data = await db_module.save_app_couch_doc_native(account_profile_info.app_id, agent_doc);
|
|
4139
4919
|
|
|
4920
|
+
const { changed, values } = diff_agent_config(config_before, agentConfig);
|
|
4921
|
+
if (changed.length) log_agent_activity(uid, agent_id, 'updated', { by: 'ai', changed, values, note: 'written by the agent builder' }, account_profile_info.app_id);
|
|
4922
|
+
|
|
4140
4923
|
return { code: 12, data };
|
|
4141
4924
|
} catch (err) {
|
|
4142
4925
|
return { code: -12, data: err.message };
|
|
@@ -4166,12 +4949,38 @@ export const create_ai_agent = async function (req, job_id, headers) {
|
|
|
4166
4949
|
// const data = await db.insert(agent_doc);
|
|
4167
4950
|
|
|
4168
4951
|
const data = await db_module.save_app_couch_doc_native(account_profile_info.app_id, agent_doc);
|
|
4169
|
-
setTimeout(async () => {
|
|
4170
|
-
await update_ai_agent_properties(agent_doc, account_profile_info.app_id, uid);
|
|
4171
|
-
await update_thumbnail('ai_agent', agent_doc, account_profile_info.app_id, uid, job_id, headers, null, null, account_profile_info);
|
|
4172
|
-
}, 500);
|
|
4173
4952
|
|
|
4174
|
-
|
|
4953
|
+
// UI-78: the first row of this agent's trail. What it was created AS, so a later
|
|
4954
|
+
// "instructions changed" row has a starting point to be read against.
|
|
4955
|
+
log_agent_activity(
|
|
4956
|
+
uid,
|
|
4957
|
+
agent_doc._id,
|
|
4958
|
+
'created',
|
|
4959
|
+
{
|
|
4960
|
+
by: 'user',
|
|
4961
|
+
name: agentConfig.agent_name,
|
|
4962
|
+
model: agentConfig.agent_ai_model,
|
|
4963
|
+
tools: (agentConfig.agent_tools || []).length,
|
|
4964
|
+
visibility: agentConfig.agent_visibility,
|
|
4965
|
+
instructions_length: String(agentConfig.agent_instructions || '').length,
|
|
4966
|
+
},
|
|
4967
|
+
account_profile_info.app_id
|
|
4968
|
+
);
|
|
4969
|
+
|
|
4970
|
+
// UI-189: a new agent always has all three preparation steps ahead of it. Detached, so
|
|
4971
|
+
// the create call still returns the moment the agent exists; the card shows what is
|
|
4972
|
+
// running and stays un-openable until studio_meta.prep says it is done.
|
|
4973
|
+
run_agent_preparation({
|
|
4974
|
+
agent_doc,
|
|
4975
|
+
app_id: account_profile_info.app_id,
|
|
4976
|
+
uid,
|
|
4977
|
+
job_id,
|
|
4978
|
+
headers,
|
|
4979
|
+
account_profile_info,
|
|
4980
|
+
agentConfig,
|
|
4981
|
+
with_image: true,
|
|
4982
|
+
});
|
|
4983
|
+
|
|
4175
4984
|
return { code: 13, data };
|
|
4176
4985
|
} catch (err) {
|
|
4177
4986
|
return { code: -13, data: err.message };
|
|
@@ -4385,26 +5194,37 @@ export const update_ai_agent_properties = async function (doc, app_id, uid, fiel
|
|
|
4385
5194
|
|
|
4386
5195
|
return ret.data;
|
|
4387
5196
|
};
|
|
5197
|
+
// UI-78: what this pass DECIDED, not that it ran. The assistant name, category and
|
|
5198
|
+
// industry on the card are written here and nowhere else, so this row is the only answer
|
|
5199
|
+
// to "who chose Fintech". Collected as it goes, because each of the three is conditional:
|
|
5200
|
+
// a pass that only refreshed the assistant name must not claim it picked a category.
|
|
5201
|
+
const filled = {};
|
|
5202
|
+
|
|
4388
5203
|
if (!db_doc.studio_meta.agent_assistant_name || fields_changed.name_changed || fields_changed.agent_instructions_changed || fields_changed.all) {
|
|
4389
5204
|
db_doc.studio_meta.agent_assistant_name = await get_agent_assistant_name();
|
|
5205
|
+
filled.assistant_name = db_doc.studio_meta.agent_assistant_name;
|
|
4390
5206
|
}
|
|
4391
5207
|
if (!db_doc.agentConfig.agent_category || fields_changed.name_changed || fields_changed.all) {
|
|
4392
5208
|
db_doc.agentConfig.agent_category = await get_agent_category();
|
|
4393
5209
|
db_doc.studio_meta.agent_category = db_doc.agentConfig.agent_category;
|
|
5210
|
+
filled.category = db_doc.agentConfig.agent_category;
|
|
4394
5211
|
}
|
|
4395
5212
|
|
|
4396
5213
|
if (!db_doc.agentConfig.agent_industry || fields_changed.name_changed || fields_changed.all) {
|
|
4397
5214
|
db_doc.agentConfig.agent_industry = await get_agent_industry();
|
|
4398
5215
|
db_doc.studio_meta.agent_industry = db_doc.agentConfig.agent_industry;
|
|
5216
|
+
filled.industry = db_doc.agentConfig.agent_industry;
|
|
4399
5217
|
}
|
|
4400
5218
|
if (db_doc.agentConfig.agent_user_guide && (fields_changed.agent_user_guide_changed || fields_changed.all)) {
|
|
4401
5219
|
db_doc.agentConfig.agent_user_guide_fields = await get_agent_user_guide_fields();
|
|
4402
5220
|
db_doc.studio_meta.agent_user_guide_fields = db_doc.agentConfig.agent_user_guide_fields;
|
|
4403
5221
|
db_doc.agentConfig.agent_user_guide_steps = await get_agent_user_guide_steps(db_doc.studio_meta.agent_user_guide_fields);
|
|
4404
5222
|
db_doc.studio_meta.agent_user_guide_steps = db_doc.agentConfig.agent_user_guide_steps;
|
|
5223
|
+
filled.user_guide_form = 'rebuilt from the user guide';
|
|
4405
5224
|
}
|
|
4406
5225
|
|
|
4407
5226
|
const save_ret = await db_module.save_app_couch_doc_native(app_id, db_doc);
|
|
5227
|
+
if (Object.keys(filled).length) log_agent_activity(uid, db_doc._id, 'properties_filled', { by: 'ai', ...filled }, app_id);
|
|
4408
5228
|
return save_ret;
|
|
4409
5229
|
};
|
|
4410
5230
|
|
|
@@ -5702,6 +6522,16 @@ export const create_conversation = async function (req, job_id, headers) {
|
|
|
5702
6522
|
};
|
|
5703
6523
|
const save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, conversation_doc);
|
|
5704
6524
|
|
|
6525
|
+
// UI-202: the first row of this chat's trail. What it was opened AS, so the AI's title
|
|
6526
|
+
// and category rows that follow have something to be read against.
|
|
6527
|
+
log_chat_activity(
|
|
6528
|
+
uid,
|
|
6529
|
+
conversation_doc._id,
|
|
6530
|
+
'created',
|
|
6531
|
+
{ by: 'user', type: conversation_type, model: ai_model, reference_type, plan_mode: normalize_boolean(plan_mode), attachments: (req.attachments || []).length || undefined },
|
|
6532
|
+
account_profile_info.app_id
|
|
6533
|
+
);
|
|
6534
|
+
|
|
5705
6535
|
let contact_id, contact_doc, recipient_uid, recipient_contact_id;
|
|
5706
6536
|
|
|
5707
6537
|
if (conversation_doc.reference_type === 'contacts' && conversation_type === 'chat') {
|
|
@@ -5791,6 +6621,9 @@ const process_conversation = async function (uid, conversation_id, account_profi
|
|
|
5791
6621
|
conversation_doc.title = title.data;
|
|
5792
6622
|
|
|
5793
6623
|
await db_module.save_app_couch_doc_native(account_profile_info.app_id, conversation_doc);
|
|
6624
|
+
// UI-202: the chat is renamed from the opening words to whatever the model calls it,
|
|
6625
|
+
// which is the one thing about a chat people ask "where did that come from" about.
|
|
6626
|
+
log_chat_activity(uid, conversation_doc._id, 'title_set', { by: 'ai', title: conversation_doc.title }, account_profile_info.app_id);
|
|
5794
6627
|
}
|
|
5795
6628
|
}
|
|
5796
6629
|
/// categorize prompt
|
|
@@ -5834,6 +6667,9 @@ const process_conversation = async function (uid, conversation_id, account_profi
|
|
|
5834
6667
|
conversation_doc.category_info = category_info;
|
|
5835
6668
|
conversation_doc.process_stat = 'full';
|
|
5836
6669
|
await db_module.save_app_couch_doc_native(account_profile_info.app_id, conversation_doc);
|
|
6670
|
+
// UI-202: the category decides which picture the card wears and how the chat is grouped,
|
|
6671
|
+
// and nothing else records that the AI chose it.
|
|
6672
|
+
if (category_info?.category) log_chat_activity(uid, conversation_doc._id, 'categorized', { by: 'ai', category: category_info.category }, account_profile_info.app_id);
|
|
5837
6673
|
|
|
5838
6674
|
//enable_thumbnail_avatar_generation
|
|
5839
6675
|
const { data: account_doc } = await db_module.get_couch_doc('xuda_accounts', account_profile_info.uid);
|
|
@@ -5846,6 +6682,9 @@ const process_conversation = async function (uid, conversation_id, account_profi
|
|
|
5846
6682
|
}
|
|
5847
6683
|
|
|
5848
6684
|
await update_thumbnail(thumbnail_type, conversation_doc, account_profile_info.app_id, uid, job_id, headers, null, null, account_profile_info);
|
|
6685
|
+
// UI-202: which of the two pictures the chat got, since the card looks quite different
|
|
6686
|
+
// for a generated title picture and a stock category one.
|
|
6687
|
+
log_chat_activity(uid, conversation_doc._id, 'image_ready', { by: 'ai', source: thumbnail_type === 'conversation_title' ? 'generated from the title' : 'from the category' }, account_profile_info.app_id);
|
|
5849
6688
|
|
|
5850
6689
|
// if ((conversation_type === 'chat' && enable_thumbnail_avatar_generation) || !category_info.category) {
|
|
5851
6690
|
// await update_thumbnail('conversation_title', conversation_doc, account_profile_info.app_id, uid, job_id, headers, null, null, account_profile_info);
|
|
@@ -6133,8 +6972,15 @@ const update_conversation_mood_level = async function (uid, target_contacts = []
|
|
|
6133
6972
|
for await (const target of target_contacts) {
|
|
6134
6973
|
const account_profile_info = await get_active_account_profile_info(target.uid);
|
|
6135
6974
|
let conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_id);
|
|
6975
|
+
const previous_mood = conversation_doc.mood_level;
|
|
6136
6976
|
conversation_doc.mood_level = mood_level_obj.mood_level;
|
|
6137
6977
|
await db_module.save_app_couch_doc_native(account_profile_info.app_id, conversation_doc);
|
|
6978
|
+
// UI-202: the score that tints the row green or red on the contact timeline, and the
|
|
6979
|
+
// contact card with it. Recorded per SIDE of the exchange, in that side's own app db,
|
|
6980
|
+
// and only when it moved: a run of identical scores says nothing.
|
|
6981
|
+
if (previous_mood !== mood_level_obj.mood_level) {
|
|
6982
|
+
log_chat_activity(target.uid, conversation_id, 'mood_scored', { by: 'ai', mood_level: mood_level_obj.mood_level, previous: typeof previous_mood === 'number' ? previous_mood : undefined }, account_profile_info.app_id);
|
|
6983
|
+
}
|
|
6138
6984
|
|
|
6139
6985
|
if (update_contact) {
|
|
6140
6986
|
const account_profile_info = await get_active_account_profile_info(target.uid);
|
|
@@ -6191,7 +7037,12 @@ const contact_chat_conversation = async function (req, job_id, headers) {
|
|
|
6191
7037
|
date_created_ts: Date.now(),
|
|
6192
7038
|
ts: Date.now(),
|
|
6193
7039
|
conversation_id,
|
|
6194
|
-
|
|
7040
|
+
// `body` does not exist in this scope (the request field is `prompt`), so every
|
|
7041
|
+
// person-to-person send died here with a ReferenceError, AFTER the conversation item
|
|
7042
|
+
// had been written into the OpenAI thread and both conversation docs had been saved.
|
|
7043
|
+
// Found while wiring the inbound-message alert below, which cannot fire from a
|
|
7044
|
+
// function that throws before it reaches it.
|
|
7045
|
+
text: prompt,
|
|
6195
7046
|
reference_id: conversation_doc.reference_id,
|
|
6196
7047
|
conversation_item_reference_id,
|
|
6197
7048
|
direction: 'out',
|
|
@@ -6200,6 +7051,20 @@ const contact_chat_conversation = async function (req, job_id, headers) {
|
|
|
6200
7051
|
|
|
6201
7052
|
const save_ret = await db_module.save_app_couch_doc(sender_app_id, out_conversation_item_obj);
|
|
6202
7053
|
|
|
7054
|
+
// UI-193: tell the person on the other end. Fire and forget: an alert that fails must
|
|
7055
|
+
// not fail the message, which is already delivered by this point.
|
|
7056
|
+
notify_chat_message({
|
|
7057
|
+
to_uid: receiver_contact_doc.contact_uid,
|
|
7058
|
+
from_uid: uid,
|
|
7059
|
+
// The recipient's own contact for the SENDER, so the alert wears the face and the name
|
|
7060
|
+
// their address book has for me. Often absent (only a contact_connection request
|
|
7061
|
+
// records it), and chat_message_sender falls back from there.
|
|
7062
|
+
from_contact_id: receiver_contact_doc.connection_contact_id,
|
|
7063
|
+
conversation_id,
|
|
7064
|
+
conversation_doc: receiver_conversation_doc,
|
|
7065
|
+
text: prompt,
|
|
7066
|
+
});
|
|
7067
|
+
|
|
6203
7068
|
update_conversation_mood_level(uid, conversation_id, prompt, uid, receiver_contact_doc.contact_uid, conversation_doc.reference_type === 'contacts', account_profile_info);
|
|
6204
7069
|
|
|
6205
7070
|
return { code: 15, data: save_ret }; ////item
|
|
@@ -8152,16 +9017,18 @@ Available CPI methods: ${cpi_tools_ret.selected_methods.join(', ')}${dashboard_s
|
|
|
8152
9017
|
tools: cpi_tools_ret.tools,
|
|
8153
9018
|
});
|
|
8154
9019
|
|
|
9020
|
+
// Running, not Starting: the label stands for the whole call, which for a deck or a video
|
|
9021
|
+
// is a minute and a half. See the matching handler on the chat agent (UI-197).
|
|
8155
9022
|
agent.on('agent_tool_start', (context, tool) => {
|
|
8156
|
-
emitToDashboard('stream_phase', `
|
|
9023
|
+
emitToDashboard('stream_phase', `Running ${tool?.name?.replaceAll('_', ' ')}`, { update: true });
|
|
8157
9024
|
});
|
|
8158
9025
|
|
|
8159
|
-
// Without this the phase keeps shimmering "
|
|
9026
|
+
// Without this the phase keeps shimmering "Running <tool>" after the tool has
|
|
8160
9027
|
// already returned, so a finished deck reads as still being built. done:true settles
|
|
8161
9028
|
// the line to a checkmark, and it also means the NEXT tool opens its own line instead
|
|
8162
9029
|
// of overwriting this one, which is how a multi-tool run becomes a readable trail.
|
|
8163
9030
|
agent.on('agent_tool_end', (context, tool) => {
|
|
8164
|
-
emitToDashboard('stream_phase', `Finished ${tool?.name?.replaceAll('_', ' ')}
|
|
9031
|
+
emitToDashboard('stream_phase', `Finished ${tool?.name?.replaceAll('_', ' ')}`, { update: true, done: true });
|
|
8165
9032
|
});
|
|
8166
9033
|
|
|
8167
9034
|
emitToDashboard('stream_phase', 'Submitting dashboard request', { update: true });
|
|
@@ -8404,7 +9271,23 @@ export const set_agent_tool_consent = async (req) => {
|
|
|
8404
9271
|
}
|
|
8405
9272
|
};
|
|
8406
9273
|
|
|
8407
|
-
const get_ai_agent_tools = async function ({ ai_agent_doc, agent_id, reference_type, prompt_suggestion_activated, chat_suggestion_activated, gtp_token, uid, account_profile_info, job_id, headers, context, app_id }) {
|
|
9274
|
+
const get_ai_agent_tools = async function ({ ai_agent_doc, agent_id, reference_type, prompt_suggestion_activated, chat_suggestion_activated, agent_explicitly_selected, gtp_token, uid, account_profile_info, job_id, headers, context, app_id }) {
|
|
9275
|
+
// Does this run get the agent's REAL tools, or only the read-only ones?
|
|
9276
|
+
//
|
|
9277
|
+
// The gate below exists so a plain chat cannot fire an agent's write-capable tools at an
|
|
9278
|
+
// account the user never pointed it at. Three cases always qualified: talking to the agent
|
|
9279
|
+
// directly, a prompt suggestion, a clicked suggestion card. The gap was the fourth, and it
|
|
9280
|
+
// is the one people actually use: turning Agent Mode on and PICKING agents. Every tool the
|
|
9281
|
+
// useful agents own is `plugin` (Presentation Builder is create_pptx + modify_pptx +
|
|
9282
|
+
// read_pptx, all plugin), so a picked agent was marked ineligible, dropped from the handoff
|
|
9283
|
+
// list, and the triage router answered alone with no tools. That is why "Update
|
|
9284
|
+
// /Presentations/deck.pptx: make it fancy" came back as a confident paragraph describing an
|
|
9285
|
+
// edit that never happened: nothing could edit anything.
|
|
9286
|
+
//
|
|
9287
|
+
// Explicitly named agents are the user choosing, exactly like opening the agent's own chat.
|
|
9288
|
+
// Auto mode (an EMPTY ai_agents array, meaning "consider all of them") is NOT a choice and
|
|
9289
|
+
// stays gated, so the safety property the gate was written for survives.
|
|
9290
|
+
const agent_tools_allowed = reference_type === 'ai_agents' || prompt_suggestion_activated || chat_suggestion_activated || agent_explicitly_selected;
|
|
8408
9291
|
let tools = [];
|
|
8409
9292
|
let tool_resources = {};
|
|
8410
9293
|
let eligible_agent = true;
|
|
@@ -8414,7 +9297,7 @@ const get_ai_agent_tools = async function ({ ai_agent_doc, agent_id, reference_t
|
|
|
8414
9297
|
const consent_required = [];
|
|
8415
9298
|
|
|
8416
9299
|
const add_xuda_public_website_tool = function ({ name, description, origin, path_prefix }) {
|
|
8417
|
-
if (
|
|
9300
|
+
if (!agent_tools_allowed) {
|
|
8418
9301
|
eligible_agent = false;
|
|
8419
9302
|
return;
|
|
8420
9303
|
}
|
|
@@ -8695,7 +9578,7 @@ const get_ai_agent_tools = async function ({ ai_agent_doc, agent_id, reference_t
|
|
|
8695
9578
|
}
|
|
8696
9579
|
|
|
8697
9580
|
case 'mcp': {
|
|
8698
|
-
if (
|
|
9581
|
+
if (!agent_tools_allowed) {
|
|
8699
9582
|
eligible_agent = false;
|
|
8700
9583
|
break;
|
|
8701
9584
|
}
|
|
@@ -8716,7 +9599,7 @@ const get_ai_agent_tools = async function ({ ai_agent_doc, agent_id, reference_t
|
|
|
8716
9599
|
}
|
|
8717
9600
|
|
|
8718
9601
|
case 'cpi': {
|
|
8719
|
-
if (
|
|
9602
|
+
if (!agent_tools_allowed) {
|
|
8720
9603
|
eligible_agent = false;
|
|
8721
9604
|
break;
|
|
8722
9605
|
}
|
|
@@ -8815,7 +9698,7 @@ const get_ai_agent_tools = async function ({ ai_agent_doc, agent_id, reference_t
|
|
|
8815
9698
|
}
|
|
8816
9699
|
|
|
8817
9700
|
case 'full_stack_vps': {
|
|
8818
|
-
if (
|
|
9701
|
+
if (!agent_tools_allowed) {
|
|
8819
9702
|
eligible_agent = false;
|
|
8820
9703
|
break;
|
|
8821
9704
|
}
|
|
@@ -8867,7 +9750,7 @@ const get_ai_agent_tools = async function ({ ai_agent_doc, agent_id, reference_t
|
|
|
8867
9750
|
}
|
|
8868
9751
|
|
|
8869
9752
|
case 'image_generate': {
|
|
8870
|
-
if (
|
|
9753
|
+
if (!agent_tools_allowed) {
|
|
8871
9754
|
eligible_agent = false;
|
|
8872
9755
|
break;
|
|
8873
9756
|
}
|
|
@@ -8942,7 +9825,7 @@ const get_ai_agent_tools = async function ({ ai_agent_doc, agent_id, reference_t
|
|
|
8942
9825
|
}
|
|
8943
9826
|
|
|
8944
9827
|
case 'ai_agent': {
|
|
8945
|
-
if (
|
|
9828
|
+
if (!agent_tools_allowed) {
|
|
8946
9829
|
eligible_agent = false;
|
|
8947
9830
|
break;
|
|
8948
9831
|
}
|
|
@@ -8962,22 +9845,31 @@ const get_ai_agent_tools = async function ({ ai_agent_doc, agent_id, reference_t
|
|
|
8962
9845
|
|
|
8963
9846
|
const user_agents = await db_module.find_app_couch_query(ai_agent_doc?.reference_doc?.studio_meta?.shared_from_app_id || ai_agent_doc?.reference_doc?.studio_meta?.installed_from_app_id || app_id, opt);
|
|
8964
9847
|
for (const agent_doc of user_agents.docs) {
|
|
9848
|
+
// These are studio docs: the name lives on properties.menuName and the prompt on
|
|
9849
|
+
// agentConfig.agent_instructions. Reading the flat agent_name / agent_instructions
|
|
9850
|
+
// off the doc gave undefined, and `undefined.substring(0, 60)` threw. get_agents
|
|
9851
|
+
// catches per agent, so the throw took the WHOLE agent out of the run list and the
|
|
9852
|
+
// turn died with agent_unavailable. Anything reached only from the agent /
|
|
9853
|
+
// suggestion paths, which is the only place this case runs, failed that way.
|
|
9854
|
+
const sub_name = agent_doc.agent_name || agent_doc?.agentConfig?.agent_name || agent_doc?.properties?.menuName || agent_doc._id;
|
|
9855
|
+
const sub_instructions = agent_doc.agent_instructions || agent_doc?.agentConfig?.agent_instructions || '';
|
|
9856
|
+
if (!sub_name) continue;
|
|
8965
9857
|
const agent = new Agent({
|
|
8966
|
-
name:
|
|
8967
|
-
instructions:
|
|
9858
|
+
name: String(sub_name).substring(0, 60),
|
|
9859
|
+
instructions: sub_instructions,
|
|
8968
9860
|
});
|
|
8969
9861
|
|
|
8970
9862
|
tools.push(
|
|
8971
9863
|
agent.asTool({
|
|
8972
|
-
toolName:
|
|
8973
|
-
toolDescription:
|
|
9864
|
+
toolName: String(sub_name),
|
|
9865
|
+
toolDescription: sub_instructions,
|
|
8974
9866
|
}),
|
|
8975
9867
|
);
|
|
8976
9868
|
}
|
|
8977
9869
|
break;
|
|
8978
9870
|
}
|
|
8979
9871
|
case 'plugin': {
|
|
8980
|
-
if (
|
|
9872
|
+
if (!agent_tools_allowed) {
|
|
8981
9873
|
eligible_agent = false;
|
|
8982
9874
|
break;
|
|
8983
9875
|
}
|
|
@@ -9087,6 +9979,12 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
9087
9979
|
const reference_id = conversation_doc.reference_id;
|
|
9088
9980
|
|
|
9089
9981
|
const activate_prompt_suggestions = _.isArray(local_ai_agents) && conversation_doc.reference_type !== 'ai_agents' && !conversation_item_id;
|
|
9982
|
+
// Did the caller NAME the agents, or just switch Agent Mode on? An empty array means
|
|
9983
|
+
// "consider all of them" (auto), which is not a choice; a populated one is the user picking
|
|
9984
|
+
// specific agents in the composer, and that is what earns those agents their real tools in
|
|
9985
|
+
// an ordinary chat. Read off the raw request value: local_ai_agents is about to be filled
|
|
9986
|
+
// with every agent on the account in the auto case, which would erase the difference.
|
|
9987
|
+
const agent_explicitly_selected = _.isArray(ai_agents) && ai_agents.length > 0;
|
|
9090
9988
|
let prompt_suggestion_activated;
|
|
9091
9989
|
let chat_suggestion_activated;
|
|
9092
9990
|
let model = ai_model;
|
|
@@ -9097,8 +9995,14 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
9097
9995
|
// await _utils.delay(1000);
|
|
9098
9996
|
const conversation_id = conversation_doc._id;
|
|
9099
9997
|
|
|
9998
|
+
// Repair an interrupted turn before reusing the thread. See drop_orphaned_tool_calls: a
|
|
9999
|
+
// function_call left without its output kills the whole conversation permanently. The page is
|
|
10000
|
+
// handed over rather than re-fetched, since it was already being read here for the cursor.
|
|
9100
10001
|
const prev_conversation_items = await client.conversations.items.list(conversation_doc.reference_conversation_id, { order: 'desc' });
|
|
9101
|
-
const
|
|
10002
|
+
const surviving_conversation_items = await drop_orphaned_tool_calls(conversation_doc.reference_conversation_id, prev_conversation_items?.data || []);
|
|
10003
|
+
// Read off the SURVIVING items. This id is the `after` cursor for "what did this turn add",
|
|
10004
|
+
// and a cursor pointing at an item we just deleted is not one.
|
|
10005
|
+
const last_conversation_item = surviving_conversation_items?.[0]?.id;
|
|
9102
10006
|
|
|
9103
10007
|
const prompt_conversation_item_id = await _common.xuda_get_uuid('chat_conversation_item');
|
|
9104
10008
|
const response_conversation_item_id = await _common.xuda_get_uuid('chat_conversation_item');
|
|
@@ -9297,8 +10201,14 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
9297
10201
|
emitToDashboard('stream_phase', 'Analyzing request', { update: true });
|
|
9298
10202
|
|
|
9299
10203
|
const init_agent_hooks = function (agent) {
|
|
10204
|
+
// UI-197: the request is away, so stop claiming to be submitting it. Between the submit
|
|
10205
|
+
// and the first token a reasoning model can sit silent for twenty seconds or more, and
|
|
10206
|
+
// for the whole of it the trail used to read "Submitting chat", which describes work
|
|
10207
|
+
// that finished long ago. The next event (a tool starting, or the first chunk) relabels
|
|
10208
|
+
// this same line, so this costs one extra phase and never a stray trail entry.
|
|
9300
10209
|
agent.on('agent_start', (context, agent) => {
|
|
9301
10210
|
// emitToDashboard('stream_start');
|
|
10211
|
+
emitToDashboard('stream_phase', 'Thinking', { update: true });
|
|
9302
10212
|
});
|
|
9303
10213
|
|
|
9304
10214
|
agent.on('agent_end', (context, output) => {
|
|
@@ -9310,15 +10220,19 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
9310
10220
|
emitToDashboard('agent_handoff', nextAgent.name);
|
|
9311
10221
|
});
|
|
9312
10222
|
|
|
10223
|
+
// "Running", not "Starting": this label is what the user looks at for the WHOLE tool
|
|
10224
|
+
// call, and a plugin that generates a video or builds a deck holds it for a minute and a
|
|
10225
|
+
// half. "Starting ..." shimmering for ninety seconds reads as a request that never got
|
|
10226
|
+
// going. The chat now prints how long the step has been running next to it (UI-197).
|
|
9313
10227
|
agent.on('agent_tool_start', (context, tool, details) => {
|
|
9314
10228
|
// emitToDashboard('agent_tool_start', tool.name);
|
|
9315
|
-
emitToDashboard('stream_phase', `
|
|
10229
|
+
emitToDashboard('stream_phase', `Running ${tool?.name?.replaceAll('_', ' ')}`, { update: true });
|
|
9316
10230
|
});
|
|
9317
10231
|
|
|
9318
10232
|
// See the matching handler on the dashboard agent above: a tool that finished has to
|
|
9319
|
-
// say so, or the phase shimmers "
|
|
10233
|
+
// say so, or the phase shimmers "Running ..." for the rest of the response.
|
|
9320
10234
|
agent.on('agent_tool_end', (context, tool, result, details) => {
|
|
9321
|
-
emitToDashboard('stream_phase', `Finished ${tool?.name?.replaceAll('_', ' ')}
|
|
10235
|
+
emitToDashboard('stream_phase', `Finished ${tool?.name?.replaceAll('_', ' ')}`, { update: true, done: true });
|
|
9322
10236
|
});
|
|
9323
10237
|
};
|
|
9324
10238
|
const get_agent_instructions = function (is_agent) {
|
|
@@ -9345,11 +10259,18 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
9345
10259
|
|
|
9346
10260
|
const triage_assistant = `
|
|
9347
10261
|
|
|
9348
|
-
You are Nissim AI Xuda assistant
|
|
10262
|
+
You are Nissim AI Xuda assistant, a smart triage router.
|
|
9349
10263
|
Your job is to instantly decide who should handle the user's request:
|
|
9350
|
-
- If
|
|
9351
|
-
|
|
9352
|
-
|
|
10264
|
+
- If a specialist can do it, hand off to them. Do it silently and immediately: transferring
|
|
10265
|
+
IS the answer, so never reply describing the handoff, offering to route, or listing scope
|
|
10266
|
+
options first. The specialist asks its own questions if it needs to.
|
|
10267
|
+
- Only answer yourself when no specialist covers the request and you can settle it from
|
|
10268
|
+
what you already know.
|
|
10269
|
+
- You hold no tools of your own. Anything that has to READ or CHANGE a real thing, a file,
|
|
10270
|
+
a deck, a document, a spreadsheet, a record, or that needs current information from the
|
|
10271
|
+
web, can only be done by a specialist. Saying you have done it is a lie: hand off.
|
|
10272
|
+
- Take the request at face value. If the user asks for a web search, that is not a last
|
|
10273
|
+
resort, it is the request.
|
|
9353
10274
|
|
|
9354
10275
|
${ai_agents ? 'Never offer suggestions at the end of response' : ''}
|
|
9355
10276
|
|
|
@@ -9385,8 +10306,18 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
9385
10306
|
"Hi Sarah! You're Sarah Cohen, our VIP client from Tel Aviv, account #89231. You've been with us since 2022 and usually reach out about trading or portfolio updates. How can I help you today?"
|
|
9386
10307
|
`.trim();
|
|
9387
10308
|
|
|
9388
|
-
|
|
10309
|
+
// UI-196: teach this path the clarifying-questions protocol. The dashboard CPI agent and
|
|
10310
|
+
// the vibe path have spoken it for a while; the chat agents never did, so a Shorts Maker
|
|
10311
|
+
// that needed to know which voice to use printed its five options as a markdown list and
|
|
10312
|
+
// waited for the user to type one back. Appended for the routed agent and the triage
|
|
10313
|
+
// agent alike, since either can be the one that ends up needing to ask.
|
|
10314
|
+
return (is_agent ? identity : triage_assistant + identity) + '\n\n' + CLARIFYING_QUESTIONS_INSTRUCTION;
|
|
9389
10315
|
};
|
|
10316
|
+
// Display name -> agent DOC id, for the run's own agents. The SDK's Agent keeps only the
|
|
10317
|
+
// fields it declares (name, instructions, handoffs, tools, ...), so the `metadata` we pass
|
|
10318
|
+
// is dropped and `_currentAgent` comes back carrying nothing but the name. This is how the
|
|
10319
|
+
// saved conversation item still records which agent answered by id.
|
|
10320
|
+
const agent_id_by_name = {};
|
|
9390
10321
|
const get_agents = async function () {
|
|
9391
10322
|
if (reference_type === 'ai_agents') {
|
|
9392
10323
|
local_ai_agents = [reference_id];
|
|
@@ -9405,9 +10336,13 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
9405
10336
|
const list = [];
|
|
9406
10337
|
|
|
9407
10338
|
let modelSettings = {};
|
|
9408
|
-
let eligible_agent = true;
|
|
9409
10339
|
|
|
9410
10340
|
for (const agent_id of local_ai_agents) {
|
|
10341
|
+
// Per agent, NOT once for the loop. This used to be declared outside and AND-ed with
|
|
10342
|
+
// each agent's result, so it could only ever go false: the first ineligible agent
|
|
10343
|
+
// dropped every agent after it too, however eligible those were. On an account with a
|
|
10344
|
+
// few agents that silently emptied the handoff list.
|
|
10345
|
+
let eligible_agent = true;
|
|
9411
10346
|
try {
|
|
9412
10347
|
let ai_agent_doc = await load_ai_agent_doc(account_profile_info.app_id, agent_id);
|
|
9413
10348
|
|
|
@@ -9427,6 +10362,7 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
9427
10362
|
reference_type,
|
|
9428
10363
|
prompt_suggestion_activated,
|
|
9429
10364
|
chat_suggestion_activated,
|
|
10365
|
+
agent_explicitly_selected,
|
|
9430
10366
|
gtp_token,
|
|
9431
10367
|
uid,
|
|
9432
10368
|
account_profile_info,
|
|
@@ -9440,10 +10376,20 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
9440
10376
|
eligible_agent = eligible_agent && tools_ret.eligible_agent;
|
|
9441
10377
|
// if (!tools.length) continue;
|
|
9442
10378
|
// console.log('tool', tools);
|
|
9443
|
-
//
|
|
9444
|
-
|
|
10379
|
+
// The name is what the ROUTER sees. The SDK builds the handoff tool from it:
|
|
10380
|
+
// `transfer_to_<name>`, described as "Handoff to the <name> agent to handle the
|
|
10381
|
+
// request. <handoffDescription>". Naming it after the doc id gave the router
|
|
10382
|
+
// `transfer_to_agn_102e783c859d` with an empty description, so it had no way to know
|
|
10383
|
+
// one of those ids writes PowerPoint and another searches the web, and it answered
|
|
10384
|
+
// everything itself. Use the agent's own name, and give the SDK the description it
|
|
10385
|
+
// has been appending to nothing. Tool names allow [a-zA-Z0-9_-], so anything else in
|
|
10386
|
+
// a user-chosen name becomes an underscore before the SDK sees it.
|
|
10387
|
+
const agent_display_name = ai_agent_doc?.agentConfig?.agent_name || ai_agent_doc?.reference_doc?.properties?.menuName || ai_agent_doc?.properties?.menuName || '';
|
|
10388
|
+
const agent_name = (agent_display_name.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') || `${ai_agent_doc._id}`).substring(0, 55);
|
|
10389
|
+
agent_id_by_name[agent_name] = ai_agent_doc?.reference_doc?._id || ai_agent_doc._id;
|
|
9445
10390
|
const agent = new Agent({
|
|
9446
|
-
name: agent_name
|
|
10391
|
+
name: agent_name,
|
|
10392
|
+
handoffDescription: (ai_agent_doc?.agentConfig?.agent_description || ai_agent_doc?.agentConfig?.agent_instructions || '').slice(0, 300),
|
|
9447
10393
|
instructions:
|
|
9448
10394
|
ai_agent_doc.agentConfig.agent_instructions +
|
|
9449
10395
|
(reference_type === 'ai_agents' ? get_agent_instructions() : '') +
|
|
@@ -9599,14 +10545,24 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
9599
10545
|
}
|
|
9600
10546
|
///////////
|
|
9601
10547
|
|
|
9602
|
-
|
|
10548
|
+
// Picking exactly ONE agent in the composer is the same instruction as opening that
|
|
10549
|
+
// agent's own chat: run it. It used to build a triage router over a list of one, and the
|
|
10550
|
+
// router answered the request itself instead of handing off, so a deliberate
|
|
10551
|
+
// "Update /Presentations/deck.pptx: make it fancy" addressed at Presentation Builder came
|
|
10552
|
+
// back as a paragraph offering to route it, from an agent holding none of the pptx tools.
|
|
10553
|
+
// Several named agents still get a router, because then there IS a routing decision.
|
|
10554
|
+
const run_single_named_agent = agent_explicitly_selected && local_ai_agents.length === 1;
|
|
10555
|
+
|
|
10556
|
+
if (reference_type === 'ai_agents' || prompt_suggestion_activated || chat_suggestion_activated || run_single_named_agent) {
|
|
9603
10557
|
_agent = agents[0];
|
|
9604
10558
|
// get_agents() can come back empty (the agent doc failed to load, or every tool it needs
|
|
9605
10559
|
// is unavailable in this scope). Running with no agent used to throw deep inside the
|
|
9606
10560
|
// runner and freeze the chat, so fail here with something the user can read.
|
|
9607
10561
|
if (!_agent) throw 'agent_unavailable';
|
|
9608
10562
|
|
|
9609
|
-
|
|
10563
|
+
// Only meaningful when the thread IS the agent (it stamps reference_id); on a plain
|
|
10564
|
+
// chat there is no agent doc at reference_id to stamp.
|
|
10565
|
+
if (reference_type === 'ai_agents') set_ts_to_agent();
|
|
9610
10566
|
} else {
|
|
9611
10567
|
// 3. Build triage agent that can hand off
|
|
9612
10568
|
_agent = new Agent({
|
|
@@ -9619,13 +10575,25 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
9619
10575
|
});
|
|
9620
10576
|
}
|
|
9621
10577
|
|
|
10578
|
+
// A clicked suggestion runs ON the thread, so it gets the thread. This used to pass ''
|
|
10579
|
+
// ("no context needed"), which cost it the conversation the user was looking at: picking
|
|
10580
|
+
// "Craft pitch slide" under a Xuda pitch deck produced a generic "Introduction to
|
|
10581
|
+
// PowerPoint Basics" deck because the agent could not see a single earlier message.
|
|
10582
|
+
//
|
|
10583
|
+
// `conversation` and `previous_response_id` are mutually exclusive at the API, and the
|
|
10584
|
+
// SDK only drops previous_response_id when conversationId is TRUTHY while forwarding
|
|
10585
|
+
// `conversation` unconditionally, so the '' also went out ALONGSIDE previous_response_id
|
|
10586
|
+
// and every click came back 400 "Mutually exclusive parameters: ''", surfaced as
|
|
10587
|
+
// "I couldn't complete that just now". Sending the thread fixes both: the response the
|
|
10588
|
+
// suggestion hangs off is already in it. The response id stays only as the fallback for
|
|
10589
|
+
// a thread with no conversation object yet.
|
|
9622
10590
|
let opt = {
|
|
9623
|
-
conversationId:
|
|
10591
|
+
conversationId: conversation_doc.reference_conversation_id,
|
|
9624
10592
|
context,
|
|
9625
10593
|
stream,
|
|
9626
10594
|
};
|
|
9627
10595
|
|
|
9628
|
-
if (chat_suggestion_activated) {
|
|
10596
|
+
if (chat_suggestion_activated && !opt.conversationId) {
|
|
9629
10597
|
opt.previousResponseId = conversation_item_doc.conversation_item_reference_id;
|
|
9630
10598
|
}
|
|
9631
10599
|
|
|
@@ -9634,6 +10602,30 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
9634
10602
|
init_agent_hooks(_agent);
|
|
9635
10603
|
// const output = await runner.run(_agent, prompt, opt);
|
|
9636
10604
|
const output = await run_agent(_agent, prompt, opt);
|
|
10605
|
+
|
|
10606
|
+
// UI-196: pull the structured clarifying-questions block off the answer, the same protocol
|
|
10607
|
+
// the dashboard CPI agent and the vibe path already use. Until now only those two spoke
|
|
10608
|
+
// it, so an AGENT that needed to ask something wrote its options as a markdown bullet
|
|
10609
|
+
// list ("Available voices you can switch to: alloy, echo, fable...") and the user had to
|
|
10610
|
+
// type one back by hand. The chat already knows how to render a picker for this; the
|
|
10611
|
+
// agent just was never told the protocol. Boaz: "refer to how the questions are prompted
|
|
10612
|
+
// in the aiChat component, so whenever u have a question render it like that".
|
|
10613
|
+
//
|
|
10614
|
+
// Resolved through a function rather than inline here because with `stream: true` the
|
|
10615
|
+
// runner returns as soon as the stream is OPEN: `_currentStep.output` is not the finished
|
|
10616
|
+
// answer until the read loop below has drained it. Called once the text is whole, which
|
|
10617
|
+
// is after that loop, and stream_end then carries the CLEANED prose in `text`:
|
|
10618
|
+
// handleStreamEnd overwrites the visible bubble with it, so the block that was streamed
|
|
10619
|
+
// out chunk by chunk never stays on screen as raw JSON.
|
|
10620
|
+
let final_output_text = '';
|
|
10621
|
+
let chat_questions = null;
|
|
10622
|
+
const resolve_answer = function () {
|
|
10623
|
+
const raw_output_text = output?.state?._currentStep?.output ?? '';
|
|
10624
|
+
const parsed = extract_xuda_questions(raw_output_text);
|
|
10625
|
+
chat_questions = parsed.questions;
|
|
10626
|
+
final_output_text = chat_questions ? parsed.prose || raw_output_text : raw_output_text;
|
|
10627
|
+
};
|
|
10628
|
+
|
|
9637
10629
|
const done = async function (output) {
|
|
9638
10630
|
try {
|
|
9639
10631
|
// const obj = { id: output.state._lastTurnResponse.responseId, ts: Date.now(), ai_agent_id: output.state._currentAgent.name, attachments, conversation_type: 'ai_chat' };
|
|
@@ -9663,16 +10655,24 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
9663
10655
|
date_created_ts: Date.now(),
|
|
9664
10656
|
ts: Date.now(),
|
|
9665
10657
|
conversation_id,
|
|
9666
|
-
|
|
10658
|
+
// The prose only. The questions ride alongside in their own field so a reload
|
|
10659
|
+
// rebuilds the picker instead of printing the JSON block back into the bubble.
|
|
10660
|
+
text: final_output_text,
|
|
9667
10661
|
reference_id: conversation_doc.reference_id,
|
|
9668
10662
|
conversation_item_reference_id: output.state._lastTurnResponse.responseId,
|
|
9669
10663
|
direction: 'in',
|
|
9670
10664
|
role: 'assistant',
|
|
9671
|
-
|
|
10665
|
+
// The agent's DOC id, off the metadata stamped when it was built, not its display
|
|
10666
|
+
// name. get_chat_suggestions compares this against agent ids to leave the agent that
|
|
10667
|
+
// just answered out of the next suggestions, and that comparison only worked while
|
|
10668
|
+
// the name happened to BE the id. Falls back to the name for the triage agent, which
|
|
10669
|
+
// has no doc behind it.
|
|
10670
|
+
ai_agent_id: agent_id_by_name[output.state._currentAgent?.name] || output.state._currentAgent.name,
|
|
9672
10671
|
job_id,
|
|
9673
10672
|
prompt_conversation_item_id,
|
|
9674
10673
|
prompt_suggestions,
|
|
9675
10674
|
prompt_selected_suggestion,
|
|
10675
|
+
...(chat_questions ? { questions: chat_questions } : {}),
|
|
9676
10676
|
};
|
|
9677
10677
|
|
|
9678
10678
|
// if (activate_prompt_suggestions) {
|
|
@@ -9747,11 +10747,16 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
9747
10747
|
stop_abort_watch();
|
|
9748
10748
|
}
|
|
9749
10749
|
// console.log('string_debug', string_debug);
|
|
9750
|
-
|
|
10750
|
+
resolve_answer();
|
|
10751
|
+
emitToDashboard('stream_end', undefined, chat_questions ? { questions: chat_questions, text: final_output_text } : undefined);
|
|
9751
10752
|
}
|
|
9752
10753
|
// else {
|
|
9753
10754
|
// await update_job('finalizing');
|
|
9754
10755
|
|
|
10756
|
+
// Non-streaming runs never reached the resolve above, and `done` persists whatever it
|
|
10757
|
+
// finds in final_output_text, so an empty one would save an empty answer.
|
|
10758
|
+
if (!stream) resolve_answer();
|
|
10759
|
+
|
|
9755
10760
|
const save_ret = await done(output);
|
|
9756
10761
|
|
|
9757
10762
|
return {
|
|
@@ -9773,6 +10778,17 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
9773
10778
|
const reason = typeof err === 'string' ? err : err?.message || String(err);
|
|
9774
10779
|
const aborted = reason === 'aborted';
|
|
9775
10780
|
|
|
10781
|
+
// The AI PROVIDER's account is out of credit or quota. Worth telling apart from every other
|
|
10782
|
+
// failure, for two reasons. First, "Please try again in a moment" is untrue: a moment will
|
|
10783
|
+
// not fix it, and the card hands the user a Try again button that cannot ever succeed, so
|
|
10784
|
+
// they sit there pressing it. Second, it is not the same thing as the customer running out
|
|
10785
|
+
// of THEIR Xuda credits (validate_credits_limit, checked before the run, with its own
|
|
10786
|
+
// "top up" message), so it must not send them to a billing page that is not the problem.
|
|
10787
|
+
// This is our bill, and the only person who can act on it is whoever owns the platform
|
|
10788
|
+
// account. dev sat on this for an entire session: every chat came back as the generic card
|
|
10789
|
+
// while the log underneath said "You have no credits remaining" 59 times.
|
|
10790
|
+
const provider_out_of_credit = /no credits remaining|insufficient[_ ]quota|exceeded your current quota|billing_hard_limit/i.test(reason);
|
|
10791
|
+
|
|
9776
10792
|
// A bare stream_end is dropped by the client: handleStreamDelta ignores deltas with no
|
|
9777
10793
|
// streaming bubble, and handleStreamEnd returns early when that bubble has no text, so the
|
|
9778
10794
|
// chat keeps ticking on its last phase forever. Open the bubble, say what happened, then
|
|
@@ -9787,14 +10803,64 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
9787
10803
|
response_started
|
|
9788
10804
|
? '\n\nStopped.'
|
|
9789
10805
|
: 'Stopped.'
|
|
9790
|
-
:
|
|
9791
|
-
?
|
|
9792
|
-
|
|
10806
|
+
: provider_out_of_credit
|
|
10807
|
+
? // Never the provider's own sentence: it names the vendor and links their billing
|
|
10808
|
+
// page, neither of which is the customer's business or any use to them.
|
|
10809
|
+
'The AI service is unavailable right now. This is a problem on our side, not with your account, and retrying will not help until it is fixed.'
|
|
10810
|
+
: reason === 'agent_unavailable'
|
|
10811
|
+
? "This agent isn't available right now. Please try again in a moment."
|
|
10812
|
+
: "I couldn't complete that just now. Please try again in a moment.",
|
|
9793
10813
|
);
|
|
9794
10814
|
// `aborted` is flagged rather than left undefined so the chat-finished alert can tell
|
|
9795
10815
|
// a stopped run from a finished one. The client only reads specific keys off params,
|
|
9796
|
-
// so the extra flag changes nothing it renders.
|
|
9797
|
-
|
|
10816
|
+
// so the extra flag changes nothing it renders. `unretryable` is what drops the Try again
|
|
10817
|
+
// button on the error card: offering it for a failure that cannot succeed is worse than
|
|
10818
|
+
// offering nothing, because the user reads the button as "this might work".
|
|
10819
|
+
emitToDashboard('stream_end', undefined, aborted ? { aborted: true } : { error: true, ...(provider_out_of_credit ? { unretryable: true } : {}) });
|
|
10820
|
+
|
|
10821
|
+
// PERSIST the outcome, do not leave it living only on the socket. Everything above is a
|
|
10822
|
+
// websocket push and nothing else: the failure was never written to the thread. So a user
|
|
10823
|
+
// whose socket had dropped, who was on another tab, or who simply reloaded, was left with
|
|
10824
|
+
// their own message and NOTHING under it, which is indistinguishable from a request that
|
|
10825
|
+
// is still thinking. That is exactly what Boaz saw: two prompts, no reply, no error, on a
|
|
10826
|
+
// conversation the server had already closed 4 seconds in.
|
|
10827
|
+
//
|
|
10828
|
+
// Same id the stream used (response_conversation_item_id, already announced on every
|
|
10829
|
+
// emit), so the client's live bubble and the reloaded item are the same message rather
|
|
10830
|
+
// than two. `is_request_error` is what the chat reads to render the error card; a STOPPED
|
|
10831
|
+
// run is not an error, so it keeps the partial answer as an ordinary message, matching
|
|
10832
|
+
// what handleStreamEnd does with the same flags live.
|
|
10833
|
+
//
|
|
10834
|
+
// Its own try/catch: a thread that fails to record a failure must still return the
|
|
10835
|
+
// failure, never a save error thrown from inside the error path.
|
|
10836
|
+
try {
|
|
10837
|
+
await db_module.save_app_couch_doc_native(account_profile_info.app_id, {
|
|
10838
|
+
_id: response_conversation_item_id,
|
|
10839
|
+
stat: 3,
|
|
10840
|
+
docType: 'chat_conversation_item',
|
|
10841
|
+
uid,
|
|
10842
|
+
conversation_type: 'ai_chat',
|
|
10843
|
+
type: 'ai_chat',
|
|
10844
|
+
date_created_ts: Date.now(),
|
|
10845
|
+
ts: Date.now(),
|
|
10846
|
+
conversation_id,
|
|
10847
|
+
// Whatever actually reached the user: on an abort that is the partial answer plus
|
|
10848
|
+
// "Stopped.", on a failure the sentence emitted just above. Built by emitToDashboard
|
|
10849
|
+
// itself, so the stored message cannot drift from the streamed one.
|
|
10850
|
+
text: stream_delta_text,
|
|
10851
|
+
reference_id: conversation_doc.reference_id,
|
|
10852
|
+
direction: 'in',
|
|
10853
|
+
role: 'assistant',
|
|
10854
|
+
job_id,
|
|
10855
|
+
prompt_conversation_item_id,
|
|
10856
|
+
...(aborted ? { aborted: true } : { is_request_error: true }),
|
|
10857
|
+
// Survives a reload, so a thread reopened tomorrow still shows the card without the
|
|
10858
|
+
// dead button rather than growing one back.
|
|
10859
|
+
...(provider_out_of_credit ? { unretryable: true } : {}),
|
|
10860
|
+
});
|
|
10861
|
+
} catch (save_err) {
|
|
10862
|
+
console.error('[ai_chat_conversation] failed to persist failure item:', save_err?.message || save_err);
|
|
10863
|
+
}
|
|
9798
10864
|
|
|
9799
10865
|
// Same rule for the HTTP body as for the stream. The raw reason is not safe to hand back:
|
|
9800
10866
|
// a failed couch call reports the connection string, and that string carries the admin
|
|
@@ -10118,6 +11184,15 @@ Do not mention that the reply is automated.
|
|
|
10118
11184
|
Use the conversation history for context.
|
|
10119
11185
|
Return only the email body.`;
|
|
10120
11186
|
|
|
11187
|
+
// Same repair the chat path does, for the same reason: this thread is reused on every
|
|
11188
|
+
// incoming message, so one interrupted tool call permanently kills automatic replies to
|
|
11189
|
+
// that contact and nothing here would ever say why. Worse than in the chat, in fact: there
|
|
11190
|
+
// is no person watching to notice it stopped answering and no Retry to press, the replies
|
|
11191
|
+
// just quietly stop. Unlike the chat path there is no item list already in hand, so this
|
|
11192
|
+
// one costs a list call. An auto reply happens once per incoming message, and the price of
|
|
11193
|
+
// skipping it is a contact who never hears back again.
|
|
11194
|
+
await drop_orphaned_tool_calls(contact_doc.contact_reference_conversation_id);
|
|
11195
|
+
|
|
10121
11196
|
const output = await runner.run(active_agent, prompt, {
|
|
10122
11197
|
conversationId: contact_doc.contact_reference_conversation_id,
|
|
10123
11198
|
context,
|
|
@@ -10607,7 +11682,11 @@ const get_plugin_tool = async function (app_id, uid, plugin_name, method, prop_d
|
|
|
10607
11682
|
}
|
|
10608
11683
|
};
|
|
10609
11684
|
|
|
10610
|
-
|
|
11685
|
+
// UI-210: `opts.pad` (a 0-1 share, or true for the default) puts the generated subject on a
|
|
11686
|
+
// transparent canvas with a margin around it before upload, for callers whose picture is
|
|
11687
|
+
// drawn full-bleed and therefore needs the room to be in the artwork. Additive and last, so
|
|
11688
|
+
// every existing positional call is untouched.
|
|
11689
|
+
const create_and_upload_image_to_drive = async function (drive_type, file_path, file_name, prompt, numGenerations, uid, app_id, job_id, headers = {}, is_system, tags = [], account_profile_info, width = 256, height = 256, opts = {}) {
|
|
10611
11690
|
try {
|
|
10612
11691
|
// 1. Generate the images
|
|
10613
11692
|
let result = await create_image(uid, prompt, undefined, undefined, numGenerations, width, height, { drive_type, file_path, file_name }, account_profile_info);
|
|
@@ -10627,6 +11706,12 @@ const create_and_upload_image_to_drive = async function (drive_type, file_path,
|
|
|
10627
11706
|
data = matches[2];
|
|
10628
11707
|
}
|
|
10629
11708
|
|
|
11709
|
+
// UI-210: opt-in margin around the subject, for pictures drawn full-bleed.
|
|
11710
|
+
if (opts.pad) {
|
|
11711
|
+
data = await pad_transparent_subject(data, typeof opts.pad === 'number' ? { scale: opts.pad } : {});
|
|
11712
|
+
ext = 'png';
|
|
11713
|
+
}
|
|
11714
|
+
|
|
10630
11715
|
// B. Write to temp file
|
|
10631
11716
|
const buffer = Buffer.from(data, 'base64');
|
|
10632
11717
|
const originalname = file_name ? `${file_name}.${ext}` : `generated_${Date.now()}_${index}.${ext}`;
|
|
@@ -10772,49 +11857,34 @@ export const get_profile_avatar = async function (profile_picture, uid, prompt,
|
|
|
10772
11857
|
const tempOutputPath = path.join(tempDir, `output_${uniqueId}.png`);
|
|
10773
11858
|
const { is_user } = metadata;
|
|
10774
11859
|
let filename = email || _id || name;
|
|
10775
|
-
|
|
10776
|
-
|
|
10777
|
-
|
|
10778
|
-
|
|
10779
|
-
|
|
10780
|
-
|
|
10781
|
-
|
|
10782
|
-
|
|
10783
|
-
|
|
10784
|
-
|
|
10785
|
-
|
|
10786
|
-
|
|
10787
|
-
|
|
10788
|
-
|
|
10789
|
-
|
|
10790
|
-
|
|
10791
|
-
|
|
10792
|
-
|
|
10793
|
-
|
|
10794
|
-
|
|
10795
|
-
|
|
10796
|
-
|
|
10797
|
-
|
|
10798
|
-
|
|
10799
|
-
|
|
10800
|
-
needs_restoration: z.boolean().describe('true if the photo is old, scanned, scratched, faded, grainy, low-detail, or otherwise degraded such that face-restoration would noticeably improve it'),
|
|
10801
|
-
}),
|
|
10802
|
-
metadata: { _id, func: 'detect_real_person_in_image' },
|
|
10803
|
-
account_profile_info,
|
|
10804
|
-
});
|
|
10805
|
-
|
|
10806
|
-
const res = JSON5.parse(ret.data);
|
|
10807
|
-
return {
|
|
10808
|
-
is_real_person_in_picture: Boolean(res?.is_real_person_in_picture),
|
|
10809
|
-
is_front_facing: Boolean(res?.is_front_facing),
|
|
10810
|
-
is_face_too_cropped: Boolean(res?.is_face_too_cropped),
|
|
10811
|
-
is_too_blurry: Boolean(res?.is_too_blurry),
|
|
10812
|
-
is_too_small: Boolean(res?.is_too_small),
|
|
10813
|
-
needs_restoration: Boolean(res?.needs_restoration),
|
|
10814
|
-
};
|
|
11860
|
+
// inspect_profile_picture tests ret.code before reading it; this one did not.
|
|
11861
|
+
// submit_chat_gpt_prompt reports a FAILED call as
|
|
11862
|
+
// { code: -5, data: err.message }, putting prose in the very field the success
|
|
11863
|
+
// path fills with JSON, so a rate limit or a model hiccup arrived here as a
|
|
11864
|
+
// sentence and JSON5 threw on a word of it. The throw escaped all the way to
|
|
11865
|
+
// get_profile_avatar's outer catch, which turned a transient AI failure into a
|
|
11866
|
+
// dead avatar job reading "JSON5: invalid character 'Y' at 1:5".
|
|
11867
|
+
//
|
|
11868
|
+
// Which way to fail matters more than the guard. The caller sends anything it
|
|
11869
|
+
// reads as not-a-person down the FICTIONAL path, so an all-false default would
|
|
11870
|
+
// answer an AI outage by inventing a face and putting it on a Level 2 account
|
|
11871
|
+
// that has just proved whose face it should be. Not knowing must never mean
|
|
11872
|
+
// "not a person": the photo that reaches here has already passed
|
|
11873
|
+
// inspect_profile_picture in the picture window, which is what establishes
|
|
11874
|
+
// that it IS a photograph of a person. So an unknown verdict falls towards
|
|
11875
|
+
// their own photo, and the worst case becomes a plain cut-out of it.
|
|
11876
|
+
const PERSON_INSPECTION_UNKNOWN = {
|
|
11877
|
+
is_real_person_in_picture: true,
|
|
11878
|
+
is_front_facing: true,
|
|
11879
|
+
is_face_too_cropped: false,
|
|
11880
|
+
is_too_blurry: false,
|
|
11881
|
+
is_too_small: false,
|
|
11882
|
+
// No restoration on a guess: it costs a second image round trip and is only
|
|
11883
|
+
// worth spending on evidence.
|
|
11884
|
+
needs_restoration: false,
|
|
10815
11885
|
};
|
|
10816
11886
|
|
|
10817
|
-
const
|
|
11887
|
+
const inspect_person_in_image = async function (base64) {
|
|
10818
11888
|
try {
|
|
10819
11889
|
const ret = await submit_chat_gpt_prompt({
|
|
10820
11890
|
uid,
|
|
@@ -10825,32 +11895,44 @@ export const get_profile_avatar = async function (profile_picture, uid, prompt,
|
|
|
10825
11895
|
content: [
|
|
10826
11896
|
{
|
|
10827
11897
|
type: 'input_text',
|
|
10828
|
-
text: `
|
|
11898
|
+
text: `Inspect this profile image. Return whether it contains a real human portrait suitable for an authentic account avatar. Mark it unsuitable if the face is not visible, side-facing, heavily cropped, very blurry, tiny, or not a real person.`,
|
|
11899
|
+
},
|
|
11900
|
+
{
|
|
11901
|
+
type: 'input_image',
|
|
11902
|
+
image_url: `data:image/png;base64,${base64}`,
|
|
10829
11903
|
},
|
|
10830
|
-
{ type: 'input_image', image_url: `data:image/png;base64,${base64}` },
|
|
10831
11904
|
],
|
|
10832
11905
|
},
|
|
10833
11906
|
],
|
|
10834
11907
|
response_format: z.object({
|
|
10835
|
-
|
|
10836
|
-
|
|
10837
|
-
|
|
10838
|
-
|
|
11908
|
+
is_real_person_in_picture: z.boolean().describe('true if a real human portrait is visible'),
|
|
11909
|
+
is_front_facing: z.boolean().describe('true if the face is mostly front-facing or only slightly angled'),
|
|
11910
|
+
is_face_too_cropped: z.boolean().describe('true if important face/head parts are cut off'),
|
|
11911
|
+
is_too_blurry: z.boolean().describe('true if the face is too blurry for an authentic avatar'),
|
|
11912
|
+
is_too_small: z.boolean().describe('true if the portrait is too small or low-detail'),
|
|
11913
|
+
needs_restoration: z.boolean().describe('true if the photo is old, scanned, scratched, faded, grainy, low-detail, or otherwise degraded such that face-restoration would noticeably improve it'),
|
|
10839
11914
|
}),
|
|
10840
|
-
metadata: { _id, func: '
|
|
11915
|
+
metadata: { _id, func: 'detect_real_person_in_image' },
|
|
10841
11916
|
account_profile_info,
|
|
10842
11917
|
});
|
|
11918
|
+
|
|
11919
|
+
if (!ret || ret.code < 0) {
|
|
11920
|
+
console.error('inspect_person_in_image failed:', ret?.data);
|
|
11921
|
+
return PERSON_INSPECTION_UNKNOWN;
|
|
11922
|
+
}
|
|
11923
|
+
|
|
10843
11924
|
const res = JSON5.parse(ret.data);
|
|
10844
|
-
if (!res || typeof res.face_height !== 'number' || res.face_height <= 0) return null;
|
|
10845
11925
|
return {
|
|
10846
|
-
|
|
10847
|
-
|
|
10848
|
-
|
|
10849
|
-
|
|
11926
|
+
is_real_person_in_picture: Boolean(res?.is_real_person_in_picture),
|
|
11927
|
+
is_front_facing: Boolean(res?.is_front_facing),
|
|
11928
|
+
is_face_too_cropped: Boolean(res?.is_face_too_cropped),
|
|
11929
|
+
is_too_blurry: Boolean(res?.is_too_blurry),
|
|
11930
|
+
is_too_small: Boolean(res?.is_too_small),
|
|
11931
|
+
needs_restoration: Boolean(res?.needs_restoration),
|
|
10850
11932
|
};
|
|
10851
11933
|
} catch (err) {
|
|
10852
|
-
console.error('
|
|
10853
|
-
return
|
|
11934
|
+
console.error('inspect_person_in_image failed:', err.message);
|
|
11935
|
+
return PERSON_INSPECTION_UNKNOWN;
|
|
10854
11936
|
}
|
|
10855
11937
|
};
|
|
10856
11938
|
|
|
@@ -11074,10 +12156,12 @@ export const get_profile_avatar = async function (profile_picture, uid, prompt,
|
|
|
11074
12156
|
const can_create_authentic_avatar = source_quality.is_usable && person_inspection.is_real_person_in_picture && !person_inspection.is_too_blurry && !person_inspection.is_too_small;
|
|
11075
12157
|
|
|
11076
12158
|
if (can_create_authentic_avatar) {
|
|
11077
|
-
|
|
12159
|
+
// No face box any more. The framing is measured off the cut-out
|
|
12160
|
+
// itself (frameSubjectAsAvatar), so asking a vision model where the
|
|
12161
|
+
// face is bought nothing but a round trip and a number that could be
|
|
12162
|
+
// wrong — and being wrong is what cropped a real avatar's head off.
|
|
11078
12163
|
imageBase64 = await normalizeAuthenticProfileAvatar(imageBase64, {
|
|
11079
12164
|
remove_background: !image_blob_ret.is_transparent,
|
|
11080
|
-
face_box,
|
|
11081
12165
|
});
|
|
11082
12166
|
avatar_source = 'authentic profile';
|
|
11083
12167
|
} else {
|
|
@@ -11347,6 +12431,9 @@ export const conversation_actions = async function (req, job_id, headers) {
|
|
|
11347
12431
|
switch (action) {
|
|
11348
12432
|
case 'print':
|
|
11349
12433
|
case 'download': {
|
|
12434
|
+
// UI-202: an answer leaving the chat as a file. Same row for both, because print and
|
|
12435
|
+
// download are the same pdf taking two roads out.
|
|
12436
|
+
log_chat_activity(uid, conversation_id, 'answer_downloaded', { by: 'user', action }, account_profile_info.app_id);
|
|
11350
12437
|
return {
|
|
11351
12438
|
code: 20,
|
|
11352
12439
|
data: '',
|
|
@@ -11370,6 +12457,9 @@ export const conversation_actions = async function (req, job_id, headers) {
|
|
|
11370
12457
|
|
|
11371
12458
|
const drive_ret = await drive_ms.upload_drive_file_user({ uid, path: '/' }, job_id, headers, file_obj);
|
|
11372
12459
|
|
|
12460
|
+
// UI-202
|
|
12461
|
+
log_chat_activity(uid, conversation_id, 'answer_saved_to_drive', { by: 'user', filename: originalname }, account_profile_info.app_id);
|
|
12462
|
+
|
|
11373
12463
|
return {
|
|
11374
12464
|
code: 20,
|
|
11375
12465
|
data: drive_ret.data,
|
|
@@ -11556,7 +12646,9 @@ const create_ai_agent_image = async function (req, job_id, headers) {
|
|
|
11556
12646
|
`;
|
|
11557
12647
|
|
|
11558
12648
|
const generate_faceless = async () => {
|
|
11559
|
-
|
|
12649
|
+
// UI-210: pad, same as the owner-portrait path below. Both end up on the same card,
|
|
12650
|
+
// drawn full-bleed, so both need the room to be in the artwork.
|
|
12651
|
+
const images_arr = await create_and_upload_image_to_drive('studio', 'Progs Thumbnails', ai_agent_id, faceless_prompt, 1, uid, app_id, job_id, headers, false, tags, account_profile_info, 1024, 1024, { pad: true });
|
|
11560
12652
|
return { code: 1, data: images_arr[0] };
|
|
11561
12653
|
};
|
|
11562
12654
|
|
|
@@ -11632,7 +12724,8 @@ const create_ai_agent_image = async function (req, job_id, headers) {
|
|
|
11632
12724
|
Use a consistent palette and emphasize intelligence, clarity, and sophistication.
|
|
11633
12725
|
`;
|
|
11634
12726
|
|
|
11635
|
-
|
|
12727
|
+
// UI-210: the moderation fallback lands on the same card, so it gets the same margin.
|
|
12728
|
+
const images_arr = await create_and_upload_image_to_drive('studio', 'Progs Thumbnails', ai_agent_id, fallback_prompt, 1, uid, app_id, job_id, headers, false, tags, account_profile_info, 1024, 1024, { pad: true });
|
|
11636
12729
|
report_ai_status(model, err);
|
|
11637
12730
|
return { code: 1, data: images_arr[0] };
|
|
11638
12731
|
}
|
|
@@ -11641,13 +12734,10 @@ const create_ai_agent_image = async function (req, job_id, headers) {
|
|
|
11641
12734
|
}
|
|
11642
12735
|
imageBase64 = ai_avatar_response.data[0].b64_json;
|
|
11643
12736
|
account_msa.record_ai_usage(uid, ai_avatar_response.usage.input_tokens, ai_avatar_response.usage.output_tokens, 'agent avatar', prompt, model, { ai_agent_id }, account_profile_info);
|
|
12737
|
+
// UI-210: pad the figure onto its canvas instead of only normalizing the size, so the
|
|
12738
|
+
// card draws it with room around it rather than edge to edge.
|
|
11644
12739
|
console.log('Normalizing final avatar to 1024 with transparent padding...');
|
|
11645
|
-
imageBase64 = await normalizeBase64To1024(imageBase64);
|
|
11646
|
-
|
|
11647
|
-
// imageBase64 = await normalizeBase64To1024(imageBase64, {
|
|
11648
|
-
// fit: 'contain', // Preserve aspect ratio, fit within 211x211
|
|
11649
|
-
// background: { r: 0, g: 0, b: 0, alpha: 0 }, // Transparent background for padding
|
|
11650
|
-
// });
|
|
12740
|
+
imageBase64 = await pad_transparent_subject(await normalizeBase64To1024(imageBase64));
|
|
11651
12741
|
|
|
11652
12742
|
const outputBuffer = Buffer.from(imageBase64, 'base64');
|
|
11653
12743
|
const outputPath = tempOutputPath;
|
|
@@ -11791,6 +12881,9 @@ export const pin_ai_chat = async function (req) {
|
|
|
11791
12881
|
|
|
11792
12882
|
const save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, conversation_doc);
|
|
11793
12883
|
|
|
12884
|
+
// UI-202
|
|
12885
|
+
log_chat_activity(uid, conversation_id, 'pinned', { by: 'user' }, account_profile_info.app_id);
|
|
12886
|
+
|
|
11794
12887
|
ws_dashboard_msa.emit_message_to_dashboard({
|
|
11795
12888
|
service: 'ai_chat_pinned',
|
|
11796
12889
|
to: [uid],
|
|
@@ -11816,6 +12909,9 @@ export const unpin_ai_chat = async function (req) {
|
|
|
11816
12909
|
|
|
11817
12910
|
const save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, conversation_doc);
|
|
11818
12911
|
|
|
12912
|
+
// UI-202
|
|
12913
|
+
log_chat_activity(uid, conversation_id, 'unpinned', { by: 'user' }, account_profile_info.app_id);
|
|
12914
|
+
|
|
11819
12915
|
ws_dashboard_msa.emit_message_to_dashboard({
|
|
11820
12916
|
service: 'ai_chat_unpinned',
|
|
11821
12917
|
to: [uid],
|
|
@@ -11893,6 +12989,9 @@ export const pin_ai_agent = async function (req, job_id, headers) {
|
|
|
11893
12989
|
|
|
11894
12990
|
const save_ret = await db_module.save_app_couch_doc(app_id, ai_agent_doc);
|
|
11895
12991
|
|
|
12992
|
+
// UI-78
|
|
12993
|
+
log_agent_activity(uid, agent_id, 'pinned', { by: 'user' }, app_id);
|
|
12994
|
+
|
|
11896
12995
|
ws_dashboard_msa.emit_message_to_dashboard({
|
|
11897
12996
|
service: 'ai_agent_pinned',
|
|
11898
12997
|
to: [uid],
|
|
@@ -11917,6 +13016,9 @@ export const unpin_ai_agent = async function (req, job_id, headers) {
|
|
|
11917
13016
|
|
|
11918
13017
|
const save_ret = await db_module.save_app_couch_doc(app_id, ai_agent_doc);
|
|
11919
13018
|
|
|
13019
|
+
// UI-78
|
|
13020
|
+
log_agent_activity(uid, agent_id, 'unpinned', { by: 'user' }, app_id);
|
|
13021
|
+
|
|
11920
13022
|
ws_dashboard_msa.emit_message_to_dashboard({
|
|
11921
13023
|
service: 'ai_agent_unpinned',
|
|
11922
13024
|
to: [uid],
|
|
@@ -12041,6 +13143,10 @@ export const add_transcript_conversation_item = async function (uid, profile_id,
|
|
|
12041
13143
|
try {
|
|
12042
13144
|
const conversation_item_reference_id = await add_conversation_item(uid, profile_id, conversation_id, `File name: ${filename} with content: ${transcript?.data || transcript || ''}`, conversation_type, reference_id, {});
|
|
12043
13145
|
|
|
13146
|
+
// UI-202: a file read into the thread. The message that lands is the transcript, so the
|
|
13147
|
+
// name of the file it came from is only recoverable from here.
|
|
13148
|
+
log_chat_activity(uid, conversation_id, 'transcript_added', { by: 'ai', filename });
|
|
13149
|
+
|
|
12044
13150
|
report_ai_status('conversations');
|
|
12045
13151
|
return conversation_item_reference_id;
|
|
12046
13152
|
} catch (err) {
|
|
@@ -12341,81 +13447,78 @@ async function inspectAvatarSourceQuality(base64Image) {
|
|
|
12341
13447
|
};
|
|
12342
13448
|
}
|
|
12343
13449
|
|
|
13450
|
+
// The avatar spec, in the module's own words. get_profile_avatar states it in
|
|
13451
|
+
// the prompt it hands the image model on the fictional path: "person in the
|
|
13452
|
+
// center of the picture ... return only a centered head-and-shoulders portrait
|
|
13453
|
+
// facing the camera ... add top margin ... the person should cover the whole
|
|
13454
|
+
// picture", on a transparent background.
|
|
13455
|
+
//
|
|
13456
|
+
// That describes the PRODUCT, not one route to it, so this route — which reaches
|
|
13457
|
+
// the same result with sharp instead of a model — has to land in the same place.
|
|
13458
|
+
// It did not. It framed to a "passport" geometry of its own invention: the head
|
|
13459
|
+
// at 62% of the frame, sized and positioned from a vision model's face box, with
|
|
13460
|
+
// no margin guaranteed anywhere. Two of the four requirements were missed
|
|
13461
|
+
// outright, and when the face box came back short the crop took the crown off
|
|
13462
|
+
// the top of a real account's avatar.
|
|
13463
|
+
//
|
|
13464
|
+
// These two numbers are the "top margin" and a little air at the sides.
|
|
13465
|
+
// Everything else the spec asks for — centred, covering the picture,
|
|
13466
|
+
// transparent — falls out of the composition rather than being tuned.
|
|
13467
|
+
const AVATAR_SIZE = 1024;
|
|
13468
|
+
const AVATAR_TOP_MARGIN = 0.06;
|
|
13469
|
+
const AVATAR_SIDE_MARGIN = 0.02;
|
|
13470
|
+
|
|
13471
|
+
// Frame a background-removed portrait to that spec.
|
|
13472
|
+
//
|
|
13473
|
+
// Measured from the SUBJECT, never from a face box. The background is already
|
|
13474
|
+
// gone by this point, so the cut-out's own bounds say exactly where the person
|
|
13475
|
+
// is — no model in the loop, and nothing that can under-report.
|
|
13476
|
+
async function frameSubjectAsAvatar(segmentedBuffer) {
|
|
13477
|
+
const bounds = await measureOpaqueBounds(segmentedBuffer);
|
|
13478
|
+
if (!bounds) return sharp(segmentedBuffer).png({ force: true }).toBuffer();
|
|
13479
|
+
|
|
13480
|
+
// The subject's own edges rather than every opaque pixel, on BOTH axes. A
|
|
13481
|
+
// speck above the head shrinks the person to make room for a stray pixel; a
|
|
13482
|
+
// speck beside them widens the frame and pushes them off centre, which breaks
|
|
13483
|
+
// the one requirement that is hardest to notice going wrong.
|
|
13484
|
+
const left = bounds.bodyMinX;
|
|
13485
|
+
const top = bounds.crownY;
|
|
13486
|
+
const subjectW = bounds.bodyMaxX - left + 1;
|
|
13487
|
+
const subjectH = bounds.footY - top + 1;
|
|
13488
|
+
if (subjectW < 2 || subjectH < 2) return sharp(segmentedBuffer).png({ force: true }).toBuffer();
|
|
13489
|
+
|
|
13490
|
+
const subject = await sharp(segmentedBuffer).ensureAlpha().extract({ left, top, width: subjectW, height: subjectH }).png({ force: true }).toBuffer();
|
|
13491
|
+
|
|
13492
|
+
// "cover the whole picture": scaled to fill the frame apart from the margins,
|
|
13493
|
+
// so the person is as large as the spec allows instead of sitting at some
|
|
13494
|
+
// fraction of it. Aspect ratio preserved — the smaller of the two fits wins.
|
|
13495
|
+
const scale = Math.min((AVATAR_SIZE * (1 - AVATAR_SIDE_MARGIN * 2)) / subjectW, (AVATAR_SIZE * (1 - AVATAR_TOP_MARGIN)) / subjectH);
|
|
13496
|
+
const w = Math.max(1, Math.round(subjectW * scale));
|
|
13497
|
+
const h = Math.max(1, Math.round(subjectH * scale));
|
|
13498
|
+
const resized = await sharp(subject).resize(w, h, { fit: 'fill' }).png({ force: true }).toBuffer();
|
|
13499
|
+
|
|
13500
|
+
// Centred left to right, the margin above the crown, the shoulders running to
|
|
13501
|
+
// the bottom edge — which is what head-and-shoulders covering the frame looks
|
|
13502
|
+
// like. The clamp only matters for a subject wider than it is tall.
|
|
13503
|
+
return sharp({ create: { width: AVATAR_SIZE, height: AVATAR_SIZE, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } })
|
|
13504
|
+
.composite([{ input: resized, left: Math.round((AVATAR_SIZE - w) / 2), top: Math.min(Math.round(AVATAR_SIZE * AVATAR_TOP_MARGIN), AVATAR_SIZE - h) }])
|
|
13505
|
+
.png({ force: true })
|
|
13506
|
+
.toBuffer();
|
|
13507
|
+
}
|
|
13508
|
+
|
|
12344
13509
|
async function normalizeAuthenticProfileAvatar(base64Image, options = {}) {
|
|
12345
|
-
const { remove_background = true
|
|
13510
|
+
const { remove_background = true } = options;
|
|
12346
13511
|
const inputBuffer = Buffer.from(base64Image, 'base64');
|
|
12347
13512
|
const orientedBuffer = await sharp(inputBuffer).rotate().png({ force: true }).toBuffer();
|
|
12348
|
-
const orientedMeta = await sharp(orientedBuffer).metadata();
|
|
12349
13513
|
const segmentedBuffer = remove_background ? await removePortraitBackground(orientedBuffer) : orientedBuffer;
|
|
12350
13514
|
|
|
12351
|
-
const
|
|
13515
|
+
const framedBuffer = await frameSubjectAsAvatar(segmentedBuffer);
|
|
12352
13516
|
|
|
12353
|
-
const subjectBuffer = await sharp(
|
|
13517
|
+
const subjectBuffer = await sharp(framedBuffer).modulate({ brightness: 1.02, saturation: 1.04 }).sharpen({ sigma: 0.35, m1: 0.4, m2: 0.2 }).png({ quality: 98, compressionLevel: 8, force: true }).toBuffer();
|
|
12354
13518
|
|
|
12355
13519
|
return subjectBuffer.toString('base64');
|
|
12356
13520
|
}
|
|
12357
13521
|
|
|
12358
|
-
async function cropToPassportFrame(segmentedBuffer, face_box, sourceMeta) {
|
|
12359
|
-
const segmentedMeta = await sharp(segmentedBuffer).metadata();
|
|
12360
|
-
const sourceW = segmentedMeta.width || sourceMeta.width || 0;
|
|
12361
|
-
const sourceH = segmentedMeta.height || sourceMeta.height || 0;
|
|
12362
|
-
if (!sourceW || !sourceH) return cropToOpaqueBounds(segmentedBuffer);
|
|
12363
|
-
|
|
12364
|
-
const faceCenterX = (face_box.face_left + face_box.face_width / 2) * sourceW;
|
|
12365
|
-
const faceCenterY = (face_box.face_top + face_box.face_height / 2) * sourceH;
|
|
12366
|
-
const faceHeightPx = face_box.face_height * sourceH;
|
|
12367
|
-
if (!(faceHeightPx > 4)) return cropToOpaqueBounds(segmentedBuffer);
|
|
12368
|
-
|
|
12369
|
-
const faceFraction = 0.62;
|
|
12370
|
-
const frameSize = Math.max(8, Math.round(faceHeightPx / faceFraction));
|
|
12371
|
-
const verticalAnchor = 0.42;
|
|
12372
|
-
const cropLeft = Math.round(faceCenterX - frameSize / 2);
|
|
12373
|
-
const cropTop = Math.round(faceCenterY - frameSize * verticalAnchor);
|
|
12374
|
-
|
|
12375
|
-
const padLeft = Math.max(0, -cropLeft);
|
|
12376
|
-
const padTop = Math.max(0, -cropTop);
|
|
12377
|
-
const padRight = Math.max(0, cropLeft + frameSize - sourceW);
|
|
12378
|
-
const padBottom = Math.max(0, cropTop + frameSize - sourceH);
|
|
12379
|
-
|
|
12380
|
-
let workingBuffer = segmentedBuffer;
|
|
12381
|
-
if (padLeft || padTop || padRight || padBottom) {
|
|
12382
|
-
workingBuffer = await sharp(segmentedBuffer)
|
|
12383
|
-
.ensureAlpha()
|
|
12384
|
-
.extend({
|
|
12385
|
-
left: padLeft,
|
|
12386
|
-
top: padTop,
|
|
12387
|
-
right: padRight,
|
|
12388
|
-
bottom: padBottom,
|
|
12389
|
-
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
12390
|
-
})
|
|
12391
|
-
.png({ force: true })
|
|
12392
|
-
.toBuffer();
|
|
12393
|
-
}
|
|
12394
|
-
|
|
12395
|
-
const workingMeta = await sharp(workingBuffer).metadata();
|
|
12396
|
-
const wW = workingMeta.width || 0;
|
|
12397
|
-
const wH = workingMeta.height || 0;
|
|
12398
|
-
|
|
12399
|
-
const wantLeft = cropLeft + padLeft;
|
|
12400
|
-
const wantTop = cropTop + padTop;
|
|
12401
|
-
const extractLeft = Math.max(0, Math.min(Math.max(0, wW - 1), wantLeft));
|
|
12402
|
-
const extractTop = Math.max(0, Math.min(Math.max(0, wH - 1), wantTop));
|
|
12403
|
-
const extractWidth = Math.max(1, Math.min(wW - extractLeft, frameSize));
|
|
12404
|
-
const extractHeight = Math.max(1, Math.min(wH - extractTop, frameSize));
|
|
12405
|
-
|
|
12406
|
-
if (extractWidth <= 0 || extractHeight <= 0) {
|
|
12407
|
-
console.warn('cropToPassportFrame: degenerate extract region, falling back', { sourceW, sourceH, frameSize, cropLeft, cropTop, wW, wH });
|
|
12408
|
-
return cropToOpaqueBounds(segmentedBuffer);
|
|
12409
|
-
}
|
|
12410
|
-
|
|
12411
|
-
const framed = await sharp(workingBuffer).extract({ left: extractLeft, top: extractTop, width: extractWidth, height: extractHeight }).png({ force: true }).toBuffer();
|
|
12412
|
-
|
|
12413
|
-
return sharp(framed)
|
|
12414
|
-
.resize(1024, 1024, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
|
12415
|
-
.png({ force: true })
|
|
12416
|
-
.toBuffer();
|
|
12417
|
-
}
|
|
12418
|
-
|
|
12419
13522
|
async function removePortraitBackground(inputBuffer) {
|
|
12420
13523
|
const orientedBuffer = await sharp(inputBuffer).rotate().png({ force: true }).toBuffer();
|
|
12421
13524
|
const inputBlob = new Blob([orientedBuffer], { type: 'image/png' });
|
|
@@ -12429,9 +13532,11 @@ async function removePortraitBackground(inputBuffer) {
|
|
|
12429
13532
|
return Buffer.from(await outputBlob.arrayBuffer());
|
|
12430
13533
|
}
|
|
12431
13534
|
|
|
12432
|
-
|
|
12433
|
-
|
|
12434
|
-
|
|
13535
|
+
// Where the cut-out subject actually sits inside the frame. Shared by the two
|
|
13536
|
+
// framing routes: one crops to it, the other takes only its TOP edge, which on a
|
|
13537
|
+
// portrait whose background has been removed is the crown of the head.
|
|
13538
|
+
async function measureOpaqueBounds(inputBuffer) {
|
|
13539
|
+
const { data, info } = await sharp(inputBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
12435
13540
|
const { width, height, channels } = info;
|
|
12436
13541
|
const alphaThreshold = 8;
|
|
12437
13542
|
|
|
@@ -12439,31 +13544,68 @@ async function cropToOpaqueBounds(inputBuffer) {
|
|
|
12439
13544
|
let minY = height;
|
|
12440
13545
|
let maxX = -1;
|
|
12441
13546
|
let maxY = -1;
|
|
13547
|
+
const rowCounts = new Int32Array(height);
|
|
13548
|
+
const colCounts = new Int32Array(width);
|
|
12442
13549
|
|
|
12443
13550
|
for (let y = 0; y < height; y++) {
|
|
13551
|
+
let count = 0;
|
|
12444
13552
|
for (let x = 0; x < width; x++) {
|
|
12445
13553
|
const alpha = data[(y * width + x) * channels + (channels - 1)];
|
|
12446
13554
|
if (alpha > alphaThreshold) {
|
|
13555
|
+
count++;
|
|
13556
|
+
colCounts[x]++;
|
|
12447
13557
|
if (x < minX) minX = x;
|
|
12448
13558
|
if (x > maxX) maxX = x;
|
|
12449
13559
|
if (y < minY) minY = y;
|
|
12450
13560
|
if (y > maxY) maxY = y;
|
|
12451
13561
|
}
|
|
12452
13562
|
}
|
|
13563
|
+
rowCounts[y] = count;
|
|
12453
13564
|
}
|
|
12454
13565
|
|
|
12455
|
-
if (maxX < 0 || maxY < 0)
|
|
12456
|
-
|
|
12457
|
-
|
|
13566
|
+
if (maxX < 0 || maxY < 0) return null;
|
|
13567
|
+
|
|
13568
|
+
// The PERSON, as distinct from every opaque pixel. Segmenters leave specks,
|
|
13569
|
+
// and one stray pixel in a corner puts "the top of the head" in the sky, or
|
|
13570
|
+
// widens the subject so the person sits off-centre inside their own frame.
|
|
13571
|
+
// A body is substantial along a row and a column where a speck is not, so each
|
|
13572
|
+
// edge is the first line carrying real extent — measured against the largest
|
|
13573
|
+
// line of this same subject, which needs no outside estimate of how big the
|
|
13574
|
+
// person is.
|
|
13575
|
+
let widestRow = 0;
|
|
13576
|
+
for (let y = minY; y <= maxY; y++) if (rowCounts[y] > widestRow) widestRow = rowCounts[y];
|
|
13577
|
+
let tallestCol = 0;
|
|
13578
|
+
for (let x = minX; x <= maxX; x++) if (colCounts[x] > tallestCol) tallestCol = colCounts[x];
|
|
13579
|
+
|
|
13580
|
+
const rowFloor = Math.max(4, Math.round(widestRow * 0.08));
|
|
13581
|
+
// Looser on the vertical than on the horizontal, deliberately. The outermost
|
|
13582
|
+
// COLUMNS of a real silhouette are genuinely short — the outer edge of a
|
|
13583
|
+
// shoulder is a few dozen pixels tall — so the row threshold applied here
|
|
13584
|
+
// would shave the person's shoulders off.
|
|
13585
|
+
const colFloor = Math.max(4, Math.round(tallestCol * 0.02));
|
|
12458
13586
|
|
|
12459
|
-
const
|
|
12460
|
-
|
|
12461
|
-
|
|
12462
|
-
|
|
12463
|
-
const
|
|
12464
|
-
|
|
13587
|
+
const firstIndex = (counts, from, to, floor) => {
|
|
13588
|
+
for (let i = from; i <= to; i++) if (counts[i] >= floor) return i;
|
|
13589
|
+
return from;
|
|
13590
|
+
};
|
|
13591
|
+
const lastIndex = (counts, from, to, floor) => {
|
|
13592
|
+
for (let i = to; i >= from; i--) if (counts[i] >= floor) return i;
|
|
13593
|
+
return to;
|
|
13594
|
+
};
|
|
12465
13595
|
|
|
12466
|
-
return
|
|
13596
|
+
return {
|
|
13597
|
+
minX,
|
|
13598
|
+
minY,
|
|
13599
|
+
maxX,
|
|
13600
|
+
maxY,
|
|
13601
|
+
// The subject's own edges, specks excluded. crownY is the top of the head.
|
|
13602
|
+
crownY: firstIndex(rowCounts, minY, maxY, rowFloor),
|
|
13603
|
+
footY: lastIndex(rowCounts, minY, maxY, rowFloor),
|
|
13604
|
+
bodyMinX: firstIndex(colCounts, minX, maxX, colFloor),
|
|
13605
|
+
bodyMaxX: lastIndex(colCounts, minX, maxX, colFloor),
|
|
13606
|
+
width,
|
|
13607
|
+
height,
|
|
13608
|
+
};
|
|
12467
13609
|
}
|
|
12468
13610
|
|
|
12469
13611
|
async function restoreFaceWithOpenAI(base64Image, ctx = {}) {
|
|
@@ -12501,6 +13643,75 @@ async function restoreFaceWithOpenAI(base64Image, ctx = {}) {
|
|
|
12501
13643
|
return outBase64;
|
|
12502
13644
|
}
|
|
12503
13645
|
|
|
13646
|
+
// UI-210: put the agent's portrait on its canvas with room around it.
|
|
13647
|
+
//
|
|
13648
|
+
// Boaz: "i didnt asked to shrink the big avatar instead recreated with padding". The
|
|
13649
|
+
// generated portrait filled its 1024 frame edge to edge, and the card draws it at
|
|
13650
|
+
// width:100% anchored to the bottom, so the figure ran from under the title straight into
|
|
13651
|
+
// the footer strip and read as a crop. Scaling it down in CSS was the wrong answer: that
|
|
13652
|
+
// leaves a hard-edged picture floating in the card's gradient. The margin belongs in the
|
|
13653
|
+
// artwork, so the file itself has space around the figure and still fills the card.
|
|
13654
|
+
//
|
|
13655
|
+
// trim() first, deliberately: the model leaves an arbitrary transparent border of its own,
|
|
13656
|
+
// so measuring the margin from the raw frame would give a different result every
|
|
13657
|
+
// generation. Trimming to the FIGURE and then padding to a fixed share makes every agent
|
|
13658
|
+
// picture sit the same way.
|
|
13659
|
+
//
|
|
13660
|
+
// Never throws. A picture with no padding is a cosmetic miss; losing the picture is not.
|
|
13661
|
+
const pad_transparent_subject = async function (base64, { canvas = 1024, scale = 0.78 } = {}) {
|
|
13662
|
+
try {
|
|
13663
|
+
const input = Buffer.from(base64, 'base64');
|
|
13664
|
+
|
|
13665
|
+
// UI-210: an OPAQUE render must not be padded. edit_image_transparent degrades to an
|
|
13666
|
+
// opaque image whenever the model refuses `background: 'transparent'` (UI-138), and
|
|
13667
|
+
// that render is a full rectangle of artwork with its own backdrop. Compositing it onto
|
|
13668
|
+
// a transparent canvas would leave a hard-edged photo floating in the middle of the
|
|
13669
|
+
// card with see-through margins around it, which looks far worse than the edge-to-edge
|
|
13670
|
+
// framing this exists to fix. The padding only makes sense for a cutout.
|
|
13671
|
+
const meta_in = await sharp(input).metadata();
|
|
13672
|
+
if (!meta_in.hasAlpha) {
|
|
13673
|
+
console.log('[pad_transparent_subject] opaque render, leaving it edge to edge');
|
|
13674
|
+
return base64;
|
|
13675
|
+
}
|
|
13676
|
+
const { data: alpha, info: alpha_info } = await sharp(input).ensureAlpha().extractChannel('alpha').raw().toBuffer({ resolveWithObject: true });
|
|
13677
|
+
let clear = 0;
|
|
13678
|
+
for (let i = 0; i < alpha.length; i++) if (alpha[i] < 8) clear++;
|
|
13679
|
+
// A real cutout leaves a good share of the frame empty. Anything below this is a
|
|
13680
|
+
// near-opaque render with at most a stray soft edge, so treat it as opaque.
|
|
13681
|
+
if (clear / (alpha_info.width * alpha_info.height) < 0.05) {
|
|
13682
|
+
console.log('[pad_transparent_subject] no usable cutout, leaving it edge to edge');
|
|
13683
|
+
return base64;
|
|
13684
|
+
}
|
|
13685
|
+
|
|
13686
|
+
let trimmed = input;
|
|
13687
|
+
try {
|
|
13688
|
+
trimmed = await sharp(input).trim().png().toBuffer();
|
|
13689
|
+
} catch (err) {
|
|
13690
|
+
// Nothing to trim: pad the original instead.
|
|
13691
|
+
}
|
|
13692
|
+
|
|
13693
|
+
const inner = Math.max(1, Math.round(canvas * scale));
|
|
13694
|
+
const resized = await sharp(trimmed)
|
|
13695
|
+
.resize(inner, inner, { fit: 'inside', withoutEnlargement: false, background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
|
13696
|
+
.png()
|
|
13697
|
+
.toBuffer();
|
|
13698
|
+
|
|
13699
|
+
const meta = await sharp(resized).metadata();
|
|
13700
|
+
const left = Math.max(0, Math.round((canvas - (meta.width || inner)) / 2));
|
|
13701
|
+
const top = Math.max(0, Math.round((canvas - (meta.height || inner)) / 2));
|
|
13702
|
+
|
|
13703
|
+
const out = await sharp({ create: { width: canvas, height: canvas, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } })
|
|
13704
|
+
.composite([{ input: resized, left, top }])
|
|
13705
|
+
.png({ quality: 98, compressionLevel: 8, force: true })
|
|
13706
|
+
.toBuffer();
|
|
13707
|
+
|
|
13708
|
+
return out.toString('base64');
|
|
13709
|
+
} catch (err) {
|
|
13710
|
+
console.error(`[pad_transparent_subject] ${err?.message || err}`);
|
|
13711
|
+
return base64;
|
|
13712
|
+
}
|
|
13713
|
+
};
|
|
13714
|
+
|
|
12504
13715
|
async function normalizeBase64To1024(
|
|
12505
13716
|
base64Image,
|
|
12506
13717
|
resize = {
|
|
@@ -18525,9 +19736,21 @@ export const update_widget_settings = async function (req) {
|
|
|
18525
19736
|
if (config[key] === '') delete doc.widget_config[key];
|
|
18526
19737
|
}
|
|
18527
19738
|
}
|
|
19739
|
+
const was_enabled = !!doc.widget_enabled;
|
|
18528
19740
|
doc.ts = Date.now();
|
|
18529
19741
|
await db_module.save_app_couch_doc_native(account_profile_info.app_id, doc);
|
|
18530
19742
|
|
|
19743
|
+
// UI-204: the chat widget is a PUBLIC surface of this profile, and it is written here
|
|
19744
|
+
// rather than through update_account_profile, so the profile trail never saw it. On or
|
|
19745
|
+
// off is the part worth reading back; the styling changes travel as "settings changed".
|
|
19746
|
+
account_msa.log_account_profile_activity({
|
|
19747
|
+
uid,
|
|
19748
|
+
app_id: account_profile_info.app_id,
|
|
19749
|
+
profile_id: account_profile_info.account_profile_id,
|
|
19750
|
+
event: typeof enabled === 'boolean' && enabled !== was_enabled ? (enabled ? 'widget_on' : 'widget_off') : 'widget_settings',
|
|
19751
|
+
detail: { by: 'user' },
|
|
19752
|
+
});
|
|
19753
|
+
|
|
18531
19754
|
return await get_widget_settings({ uid, profile_id });
|
|
18532
19755
|
} catch (err) {
|
|
18533
19756
|
return { code: -1, data: err.message || String(err) };
|
|
@@ -18648,9 +19871,21 @@ export const update_contact_form_settings = async function (req) {
|
|
|
18648
19871
|
if (config[key] === '') delete doc.contact_form_config[key];
|
|
18649
19872
|
}
|
|
18650
19873
|
}
|
|
19874
|
+
const was_enabled = !!doc.contact_form_enabled;
|
|
18651
19875
|
doc.ts = Date.now();
|
|
18652
19876
|
await db_module.save_app_couch_doc_native(account_profile_info.app_id, doc);
|
|
18653
19877
|
|
|
19878
|
+
// UI-204: same as the widget. The contact form is public and is written straight onto
|
|
19879
|
+
// the profile doc, so without this the profile trail could not answer "when did this
|
|
19880
|
+
// start accepting messages from strangers".
|
|
19881
|
+
account_msa.log_account_profile_activity({
|
|
19882
|
+
uid,
|
|
19883
|
+
app_id: account_profile_info.app_id,
|
|
19884
|
+
profile_id: account_profile_info.account_profile_id,
|
|
19885
|
+
event: typeof enabled === 'boolean' && enabled !== was_enabled ? (enabled ? 'contact_form_on' : 'contact_form_off') : 'contact_form_settings',
|
|
19886
|
+
detail: { by: 'user' },
|
|
19887
|
+
});
|
|
19888
|
+
|
|
18654
19889
|
return await get_contact_form_settings({ uid, profile_id });
|
|
18655
19890
|
} catch (err) {
|
|
18656
19891
|
return { code: -1, data: err.message || String(err) };
|