@xuda.io/ai_module 1.1.5610 → 1.1.5612

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 CHANGED
@@ -232,6 +232,7 @@ const email_ms = await import(`${module_path}/email_module/index_ms.mjs`);
232
232
  const ws_dashboard_msa = await import(`${module_path}/ws_dashboard_module/index_msa.mjs`);
233
233
  const account_msa = await import(`${module_path}/account_module/index_msa.mjs`);
234
234
  const drive_msa = await import(`${module_path}/drive_module/index_msa.mjs`);
235
+ const misc_msa = await import(`${module_path}/misc_module/index_msa.mjs`);
235
236
 
236
237
  var open_ai_status = {};
237
238
  const report_ai_status = function (model, err) {
@@ -2150,7 +2151,7 @@ const get_studio_doc = async function (req) {
2150
2151
  };
2151
2152
 
2152
2153
  export const get_ai_agent_info = async function (uid, job_id, headers, doc, from_marketplace) {
2153
- const account_profile_info = await get_active_account_profile_info(uid);
2154
+ const account_profile_info = await get_active_account_profile_info(uid, from_marketplace ? doc?.studio_meta?.account_profiles?.[0] : undefined);
2154
2155
 
2155
2156
  const get_contact_border = function (doc) {
2156
2157
  const disable_color = `#1a3557`;
@@ -2671,6 +2672,10 @@ export const delete_ai_agent = async function (req) {
2671
2672
  agent_doc.ts = Date.now();
2672
2673
  const save_ret = await db_module.save_app_couch_doc_native(account_profile_info.app_id, agent_doc);
2673
2674
 
2675
+ if (agent_doc?.agentConfig?.npm_package_name) {
2676
+ misc_msa.sync_agent_npm_state({ app_id: account_profile_info.app_id, prog_id: agent_id, event: 'deleted' });
2677
+ }
2678
+
2674
2679
  for (const [key, val] of Object.entries(agent_doc?.agentConfig?.agent_tools || [])) {
2675
2680
  const file_id = val?.youtube?.file_id || val?.file?.file_id;
2676
2681
  const vector_store_id = val?.youtube?.vector_store_id || val?.file?.vector_store_id;
@@ -2883,10 +2888,46 @@ export const delete_mini_app = async function (req) {
2883
2888
  }
2884
2889
  };
2885
2890
 
2891
+ export const start_cli_agent_conversation = async function (req) {
2892
+ const { uid, agent_id } = req;
2893
+ if (!uid) return { code: -1, data: 'uid required' };
2894
+ if (!agent_id) return { code: -1, data: 'agent_id required' };
2895
+ try {
2896
+ const account_profile_info = await get_active_account_profile_info(uid);
2897
+ const profile_id = account_profile_info.account_profile_id;
2898
+ const conversation_obj = await create_openai_conversation();
2899
+ const conversation_id = await _common.xuda_get_uuid('chat_conversation');
2900
+ const now = Date.now();
2901
+ const doc = {
2902
+ _id: conversation_id,
2903
+ docType: 'chat_conversation',
2904
+ title: 'CLI session',
2905
+ date_created_ts: now,
2906
+ ts: now,
2907
+ stat: 1,
2908
+ uid,
2909
+ messages: [],
2910
+ reference_type: 'ai_agents',
2911
+ reference_id: agent_id,
2912
+ conversation_obj,
2913
+ reference_conversation_id: conversation_obj.id,
2914
+ conversation_type: 'ai_chat',
2915
+ initiator_uid: uid,
2916
+ account_profiles: [profile_id],
2917
+ source: 'cli',
2918
+ };
2919
+ const save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, doc);
2920
+ if (save_ret.code < 0) return save_ret;
2921
+ return { code: 1, data: { conversation_id, profile_id, app_id: account_profile_info.app_id } };
2922
+ } catch (err) {
2923
+ return { code: -1, data: err.message };
2924
+ }
2925
+ };
2926
+
2886
2927
  export const update_ai_agent = async function (req, job_id, headers) {
2887
- let { agent_id, agentConfig, uid } = req;
2928
+ let { agent_id, agentConfig, uid, profile_id } = req;
2888
2929
 
2889
- const account_profile_info = await get_active_account_profile_info(uid);
2930
+ const account_profile_info = await get_active_account_profile_info(uid, profile_id);
2890
2931
 
2891
2932
  try {
2892
2933
  let agent_doc = await await db_module.get_app_couch_doc_native(account_profile_info.app_id, agent_id);
@@ -3292,11 +3333,11 @@ const save_agent_status = async function (uid, agent_id, stat, agentConfig) {
3292
3333
  };
3293
3334
 
3294
3335
  export const create_ai_agent = async function (req, job_id, headers) {
3295
- const { agentConfig, uid } = req;
3336
+ const { agentConfig, uid, profile_id } = req;
3296
3337
 
3297
3338
  const { account_project_id: app_id, account_info } = await account_ms.get_default_project_account_doc(uid);
3298
3339
 
3299
- const account_profile_info = await get_active_account_profile_info(uid);
3340
+ const account_profile_info = await get_active_account_profile_info(uid, profile_id);
3300
3341
  const { account_profile_obj } = account_profile_info;
3301
3342
  try {
3302
3343
  const param = { uid, properties: { menuName: agentConfig.agent_name, menuTitle: agentConfig.agent_name, menuType: 'ai_agent' }, checkedInUserName: account_info.first_name + ' ' + account_info.last_name, app_id: account_profile_info.app_id, parentId: 'ai_agents' };
@@ -3574,7 +3615,7 @@ export const update_thumbnail = async function (type, doc, app_id, uid, job_id,
3574
3615
 
3575
3616
  switch (type) {
3576
3617
  case 'ai_agent': {
3577
- const images_arr = await create_ai_agent_image({ app_id, uid, ai_agent_id: doc._id, tags }, job_id, headers);
3618
+ const images_arr = await create_ai_agent_image({ app_id, uid, ai_agent_id: doc._id, tags, account_profile_info }, job_id, headers);
3578
3619
  db_doc = await get_db_doc();
3579
3620
  db_doc.studio_meta.thumbnail_request_ts = Date.now();
3580
3621
  db_doc.studio_meta.agent_image = [images_arr.data];
@@ -5113,8 +5154,99 @@ ${conversation_history || `User (studio): ${prompt}`}
5113
5154
  }
5114
5155
  };
5115
5156
 
5157
+ // Clarifying-questions protocol shared by the vibe/codex path AND the dashboard
5158
+ // CPI agent path. Both emit plain prose by default, so to get a structured
5159
+ // "ask the user" payload (Claude-Code-style options stepper) we instruct them
5160
+ // to emit a JSON block at the end of the response when they would otherwise
5161
+ // ask follow-up questions in prose. The block is parsed off, prose is shipped
5162
+ // as the normal text, and the structured questions ride alongside on the
5163
+ // stream_end event + the persisted chat_conversation_item.
5164
+ const CLARIFYING_QUESTIONS_INSTRUCTION = `
5165
+ If you need clarifying input from the user before proceeding, do NOT ask in prose. Instead, emit a structured questions block at the END of your response:
5166
+
5167
+ <xuda-questions>
5168
+ [
5169
+ {
5170
+ "question": "<full question text>",
5171
+ "options": [
5172
+ { "label": "<short option label>", "description": "<one-sentence explanation of what this option means>" },
5173
+ { "label": "<short option label>", "description": "<one-sentence explanation of what this option means>" },
5174
+ { "label": "<short option label>", "description": "<one-sentence explanation of what this option means>" }
5175
+ ]
5176
+ }
5177
+ ]
5178
+ </xuda-questions>
5179
+
5180
+ Rules:
5181
+ - 2 to 3 options per question. The UI automatically adds an "Other (tell me what you meant)" free-text option, so do NOT include it yourself.
5182
+ - You may include multiple questions in the array; the UI will step through them one at a time.
5183
+ - Put the block at the very end, after any explanatory prose. The prose before the block will be shown to the user as your message.
5184
+ - If you do not need clarification, do not emit the block. Only emit it when answering is genuinely blocked on missing information.
5185
+ `.trim();
5186
+
5187
+ const extract_xuda_questions = function (text) {
5188
+ if (typeof text !== 'string') return { prose: text, questions: null };
5189
+ const match = text.match(/<xuda-questions>\s*([\s\S]+?)\s*<\/xuda-questions>\s*$/);
5190
+ if (!match) return { prose: text, questions: null };
5191
+ let parsed;
5192
+ try {
5193
+ parsed = JSON.parse(match[1]);
5194
+ } catch (e) {
5195
+ return { prose: text, questions: null };
5196
+ }
5197
+ if (!Array.isArray(parsed) || !parsed.length) return { prose: text, questions: null };
5198
+ const validated = parsed
5199
+ .map((q) => {
5200
+ if (!q || typeof q.question !== 'string') return null;
5201
+ const options = Array.isArray(q.options)
5202
+ ? q.options
5203
+ .map((opt) => (opt && typeof opt.label === 'string' ? { label: opt.label.trim(), description: typeof opt.description === 'string' ? opt.description.trim() : '' } : null))
5204
+ .filter(Boolean)
5205
+ .slice(0, 3)
5206
+ : [];
5207
+ if (options.length < 2) return null;
5208
+ return { question: q.question.trim(), options };
5209
+ })
5210
+ .filter(Boolean);
5211
+ if (!validated.length) return { prose: text, questions: null };
5212
+ return {
5213
+ prose: text.slice(0, match.index).trim(),
5214
+ questions: validated,
5215
+ };
5216
+ };
5217
+
5218
+ // Resolve the structured context object passed by newer clients (ProjectAiPanel
5219
+ // sends { project_id, tab, view, vibe_mode }) or fall back to the legacy string
5220
+ // shape that older callsites still send. Returns an object — string-shaped legacy
5221
+ // inputs are wrapped as { tab: <string> } so downstream readers have one shape.
5222
+ const get_dashboard_context_obj = function (req, conversation_doc) {
5223
+ const candidate = req.context || req.metadata?.context || conversation_doc.context || conversation_doc.metadata?.context;
5224
+ if (candidate && typeof candidate === 'object') return candidate;
5225
+ const legacy_str = (candidate || req.dashboard_context || conversation_doc.dashboard_context || '').toString().trim().toLowerCase();
5226
+ return legacy_str ? { tab: legacy_str } : {};
5227
+ };
5228
+
5229
+ // Primary routing signal: replaces the legacy `dashboard_context === 'console'`
5230
+ // check. Newer clients set `context.vibe_mode = true` when the user is in vibe
5231
+ // mode (full-screen VPS preview) — that's the canonical "send this through the
5232
+ // codex/console runner" flag. dashboard_context is being deprecated as a
5233
+ // routing concept; it remains only as a hint string (context.tab) used by the
5234
+ // CPI tools agent for keyword-based tool selection in the non-vibe branch.
5235
+ const is_vibe_mode_request = function (req, conversation_doc) {
5236
+ const ctx = get_dashboard_context_obj(req, conversation_doc);
5237
+ if (ctx.vibe_mode === true) return true;
5238
+ // Backwards compat: explicit legacy `context: 'console'` still triggers the
5239
+ // codex path. Remove once no callsites pass the string form.
5240
+ if (ctx.tab === 'console') return true;
5241
+ return false;
5242
+ };
5243
+
5116
5244
  const get_dashboard_chat_context = function (req, conversation_doc) {
5117
- return (req.context || req.dashboard_context || req.metadata?.context || conversation_doc.context || conversation_doc.dashboard_context || conversation_doc.metadata?.context || '').toString().trim().toLowerCase();
5245
+ const ctx = get_dashboard_context_obj(req, conversation_doc);
5246
+ // The legacy "dashboard_context" string was an ad-hoc tab identifier; the new
5247
+ // object's `tab` field is the same concept. Returning '' here lets the CPI
5248
+ // tools agent still do prompt-only keyword matching.
5249
+ return (ctx.tab || '').toString().trim().toLowerCase();
5118
5250
  };
5119
5251
 
5120
5252
  const get_dashboard_cpi_keywords = function (context, prompt = '') {
@@ -5277,6 +5409,7 @@ const dashboard_chat = async function (req, job_id, headers) {
5277
5409
 
5278
5410
  const conversation_id = conversation_doc._id;
5279
5411
  const dashboard_context = get_dashboard_chat_context(req, conversation_doc);
5412
+ const vibe_mode = is_vibe_mode_request(req, conversation_doc);
5280
5413
  const target_app_id = (await _common.get_project_app_id(req.app_id || conversation_doc.reference_id || account_profile_info.app_id, true)) || account_profile_info.app_id;
5281
5414
  const prompt_conversation_item_id = await _common.xuda_get_uuid('chat_conversation_item');
5282
5415
  const response_conversation_item_id = await _common.xuda_get_uuid('chat_conversation_item');
@@ -5304,7 +5437,19 @@ const dashboard_chat = async function (req, job_id, headers) {
5304
5437
  ? {
5305
5438
  stream_id: response_conversation_item_id,
5306
5439
  final_seq: stream_delta_seq,
5307
- text: stream_delta_text,
5440
+ // params.text override exists for the clarifying-questions flow:
5441
+ // when the agent embedded an <xuda-questions> block in its
5442
+ // streamed output, the live stream_delta events already
5443
+ // shipped the block to the client. The caller passes the
5444
+ // cleaned prose here so handleStreamEnd can reconcile the
5445
+ // visible bubble against the block-free version (uses the
5446
+ // existing missed-deltas reconciliation path on the client).
5447
+ text: typeof params?.text === 'string' ? params.text : stream_delta_text,
5448
+ // Structured clarifying-questions payload — when the agent
5449
+ // emits an <xuda-questions> block, we parse it, strip it
5450
+ // from the prose, and ship it here so the client can render
5451
+ // the stepper artifact alongside the message.
5452
+ ...(params?.questions ? { questions: params.questions } : {}),
5308
5453
  }
5309
5454
  : {}),
5310
5455
  prompt_conversation_item_id,
@@ -5418,7 +5563,7 @@ ${conversation_history || `User (dashboard): ${prompt}`}
5418
5563
 
5419
5564
  try {
5420
5565
  emitToDashboard('stream_start');
5421
- emitToDashboard('stream_phase', dashboard_context === 'console' ? 'Starting console request' : 'Starting dashboard request');
5566
+ emitToDashboard('stream_phase', vibe_mode ? 'Starting vibe request' : 'Starting dashboard request');
5422
5567
 
5423
5568
  await saveUserItem();
5424
5569
 
@@ -5432,19 +5577,68 @@ ${conversation_history || `User (dashboard): ${prompt}`}
5432
5577
  conversation_doc.stat = 2;
5433
5578
  conversation_doc.request_started_at = request_started_at;
5434
5579
  conversation_doc.job_id = job_id;
5435
- conversation_doc.context = dashboard_context;
5580
+ // Persist the structured context (object shape) so multi-turn requests
5581
+ // inherit vibe_mode even when the client doesn't resend it. The legacy
5582
+ // string-form `context = dashboard_context` is dropped — vibe_mode is now
5583
+ // the routing signal and `tab` lives inside the object.
5584
+ conversation_doc.context = { ...get_dashboard_context_obj(req, conversation_doc), vibe_mode };
5436
5585
  conversation_doc.target_app_id = target_app_id;
5437
5586
  await db_module.save_app_couch_doc_native(account_profile_info.app_id, conversation_doc);
5438
5587
 
5439
- if (dashboard_context === 'console') {
5440
- const codex_ret = await execute_codex_request({ ...req, conversation_id, conversation_doc, prompt, attachments, stream: false }, job_id, headers);
5588
+ if (vibe_mode) {
5589
+ // Resolve the project's VPS IP codex SSHes into that host to run
5590
+ // commands. Without this, execute_codex_request was pulling `ip` from
5591
+ // `req.ip` (the user's browser IP), which never matches a real VPS
5592
+ // and either fails the SSH or hangs codex against an unreachable host.
5593
+ let vibe_app_obj = null;
5594
+ try {
5595
+ vibe_app_obj = await get_app_obj(target_app_id);
5596
+ } catch (e) {
5597
+ // get_app_obj throws on missing doc — treat as "no project found"
5598
+ // and fall through to the missing-VPS error path below.
5599
+ }
5600
+ const vps_ip = vibe_app_obj?.app_hosting_server?.ip;
5601
+ const project_name = vibe_app_obj?.menuName || vibe_app_obj?.app_name || vibe_app_obj?.name || '';
5602
+
5603
+ if (!vps_ip) {
5604
+ // No deployed VPS for this project — surface a clear, actionable
5605
+ // message instead of letting codex fail silently or SSH to the user.
5606
+ const missing_vps_msg = `This project does not have a deployed VPS yet, so I can't run vibe-mode commands. Open the project's Instances tab and deploy a VPS first, then try again.`;
5607
+ emitToDashboard('response_start');
5608
+ streamText(missing_vps_msg);
5609
+ emitToDashboard('stream_end');
5610
+ conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_id);
5611
+ conversation_doc.ts = Date.now();
5612
+ conversation_doc.stat = 3;
5613
+ conversation_doc.process_stat = 'partial';
5614
+ await db_module.save_app_couch_doc_native(account_profile_info.app_id, conversation_doc);
5615
+ return await saveAssistantItem(missing_vps_msg, { vibe_missing_vps: true });
5616
+ }
5617
+
5618
+ // System block: questions protocol + project/VPS context so codex's
5619
+ // response doesn't drift to a generic answer. Codex sees one prompt
5620
+ // string; the [System]/[User request] framing keeps the boundary
5621
+ // visible to it.
5622
+ const project_context_block = `You are operating in vibe mode for Xuda project "${project_name || target_app_id}" (app_id: ${target_app_id}).\nThe deployed VPS for this project is at ${vps_ip}; all shell commands run there over SSH.\nWhen the user asks you to build, change, or inspect something on "this VPS", that means ${vps_ip}.`;
5623
+ const codex_prompt = `[System]\n${project_context_block}\n\n${CLARIFYING_QUESTIONS_INSTRUCTION}\n\n[User request]\n${prompt}`;
5624
+
5625
+ // Override ip explicitly so the `...req` spread can't smuggle in
5626
+ // req.ip (browser IP). conversation_id / conversation_doc / prompt /
5627
+ // attachments / stream stay after the spread for the same reason.
5628
+ const codex_ret = await execute_codex_request({ ...req, ip: vps_ip, conversation_id, conversation_doc, prompt: codex_prompt, attachments, stream: false }, job_id, headers);
5441
5629
  const codex_events = codex_ret?.data?.events || [];
5442
5630
  const last_message = [...codex_events].reverse().find((event) => event?.item?.type === 'agent_message' && event.item.text)?.item?.text;
5443
- const response_text = codex_ret.code < 0 ? `Console request failed: ${get_error_message(codex_ret.data, 'Codex request failed')}` : last_message || 'Console request completed.';
5631
+ const raw_response_text = codex_ret.code < 0 ? `Vibe request failed: ${get_error_message(codex_ret.data, 'Codex request failed')}` : last_message || 'Vibe request completed.';
5632
+
5633
+ // Pull the structured questions block (if any) off the tail of the
5634
+ // response. response_text is just the prose; questions ride alongside
5635
+ // on the stream_end event + the persisted assistant item.
5636
+ const { prose, questions } = extract_xuda_questions(raw_response_text);
5637
+ const response_text = prose || raw_response_text;
5444
5638
 
5445
5639
  emitToDashboard('response_start');
5446
5640
  streamText(response_text);
5447
- emitToDashboard('stream_end');
5641
+ emitToDashboard('stream_end', undefined, questions ? { questions } : undefined);
5448
5642
 
5449
5643
  conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_id);
5450
5644
  conversation_doc.ts = Date.now();
@@ -5452,7 +5646,7 @@ ${conversation_history || `User (dashboard): ${prompt}`}
5452
5646
  conversation_doc.process_stat = codex_ret.code < 0 ? 'partial' : 'full';
5453
5647
  await db_module.save_app_couch_doc_native(account_profile_info.app_id, conversation_doc);
5454
5648
 
5455
- return await saveAssistantItem(response_text, { codex_result: codex_ret });
5649
+ return await saveAssistantItem(response_text, { codex_result: codex_ret, ...(questions ? { questions } : {}) });
5456
5650
  }
5457
5651
 
5458
5652
  const dashboard_prompt = await getDashboardContextPrompt();
@@ -5476,6 +5670,8 @@ If the screenshot or prompt implies the tab/context, infer the relevant dashboar
5476
5670
  For data/database requests, use the database/table CPI methods and keep app_id/reference_id anchored to "${target_app_id}".
5477
5671
  Summarize the action and important CPI result fields clearly. Do not invent successful changes; report tool errors as errors.
5478
5672
  Available CPI methods: ${cpi_tools_ret.selected_methods.join(', ')}
5673
+
5674
+ ${CLARIFYING_QUESTIONS_INSTRUCTION}
5479
5675
  `.trim(),
5480
5676
  model: ai_model,
5481
5677
  tools: cpi_tools_ret.tools,
@@ -5506,7 +5702,16 @@ Available CPI methods: ${cpi_tools_ret.selected_methods.join(', ')}
5506
5702
  }
5507
5703
 
5508
5704
  response_text = response_text || output?.state?._currentStep?.output || output?.finalOutput || output?.output || 'Dashboard request completed.';
5509
- emitToDashboard('stream_end');
5705
+
5706
+ // Pull off the structured clarifying-questions block (if the agent emitted
5707
+ // one) before persistence + stream_end. The block was streamed live to the
5708
+ // client during stream_delta, so the client's handleStreamEnd will use the
5709
+ // `text` field on stream_end to overwrite the visible bubble with the
5710
+ // cleaned prose (see AiChat.vue handleStreamEnd reconciliation).
5711
+ const { prose: dashboard_prose, questions: dashboard_questions } = extract_xuda_questions(response_text);
5712
+ const final_response_text = dashboard_prose || response_text;
5713
+
5714
+ emitToDashboard('stream_end', undefined, dashboard_questions ? { questions: dashboard_questions, text: final_response_text } : undefined);
5510
5715
 
5511
5716
  conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_id);
5512
5717
  conversation_doc.ts = Date.now();
@@ -5514,9 +5719,10 @@ Available CPI methods: ${cpi_tools_ret.selected_methods.join(', ')}
5514
5719
  conversation_doc.process_stat = 'full';
5515
5720
  await db_module.save_app_couch_doc_native(account_profile_info.app_id, conversation_doc);
5516
5721
 
5517
- return await saveAssistantItem(response_text, {
5722
+ return await saveAssistantItem(final_response_text, {
5518
5723
  cpi_methods: cpi_tools_ret.selected_methods,
5519
5724
  ai_agent_id: 'dashboard_cpi_agent',
5725
+ ...(dashboard_questions ? { questions: dashboard_questions } : {}),
5520
5726
  });
5521
5727
  } catch (err) {
5522
5728
  const error_message = get_error_message(err, 'dashboard request failed');
@@ -5540,9 +5746,337 @@ const get_ai_agent_tools = async function ({ ai_agent_doc, agent_id, reference_t
5540
5746
  let tool_resources = {};
5541
5747
  let eligible_agent = true;
5542
5748
 
5749
+ const add_internal_codex_tool = function ({ name, description, system_prompt }) {
5750
+ if (reference_type !== 'ai_agents' && !prompt_suggestion_activated && !chat_suggestion_activated) {
5751
+ eligible_agent = false;
5752
+ return;
5753
+ }
5754
+
5755
+ tools.push(
5756
+ tool({
5757
+ name: name.substring(0, 60),
5758
+ description,
5759
+ parameters: z.object({
5760
+ question: z.string().describe('The support question to investigate.'),
5761
+ }),
5762
+ async execute(e) {
5763
+ const question = String(e?.question || '').trim();
5764
+ if (!question) {
5765
+ return 'question is required';
5766
+ }
5767
+
5768
+ const dev_host = _conf?.internal_ai_agents?.dev_server_host || 'dev.xuda.ai';
5769
+ const codex_ret = await execute_codex_request(
5770
+ {
5771
+ ip: dev_host,
5772
+ uid,
5773
+ account_profile_info,
5774
+ job_id,
5775
+ stream: false,
5776
+ prompt: `${system_prompt}
5777
+
5778
+ User question:
5779
+ ${question}
5780
+
5781
+ Return a concise answer. Include only non-sensitive findings and mention when the requested information is outside the allowed scope.`,
5782
+ },
5783
+ job_id,
5784
+ headers,
5785
+ );
5786
+
5787
+ if (codex_ret.code < 0) {
5788
+ return `Codex readonly lookup failed: ${get_error_message(codex_ret.data, 'unknown error')}`;
5789
+ }
5790
+
5791
+ const events = codex_ret?.data?.events || [];
5792
+ const final_message = [...events].reverse().find((event) => event?.item?.type === 'agent_message' && event.item.text)?.item?.text;
5793
+ return final_message || 'Codex readonly lookup completed without a final answer.';
5794
+ },
5795
+ }),
5796
+ );
5797
+ };
5798
+
5799
+ const add_xuda_public_website_tool = function ({ name, description }) {
5800
+ if (reference_type !== 'ai_agents' && !prompt_suggestion_activated && !chat_suggestion_activated) {
5801
+ eligible_agent = false;
5802
+ return;
5803
+ }
5804
+
5805
+ const website_origin = _conf?.internal_ai_agents?.public_website_url || 'https://xuda.ai';
5806
+ const dump_dir = path.join(os.tmpdir(), 'xuda_public_website_dump', new URL(website_origin).hostname.replace(/[^a-z0-9.-]/gi, '_'));
5807
+ const manifest_path = path.join(dump_dir, 'manifest.json');
5808
+ const ttl_ms = Number(_conf?.internal_ai_agents?.public_website_dump_ttl_ms || 10 * 60 * 1000);
5809
+ const cache_version = 2;
5810
+ const website_base = website_origin.replace(/\/$/, '');
5811
+
5812
+ const decode_html = function (value = '') {
5813
+ return String(value)
5814
+ .replace(/&nbsp;/gi, ' ')
5815
+ .replace(/&amp;/gi, '&')
5816
+ .replace(/&lt;/gi, '<')
5817
+ .replace(/&gt;/gi, '>')
5818
+ .replace(/&quot;/gi, '"')
5819
+ .replace(/&#39;/g, "'");
5820
+ };
5821
+
5822
+ const strip_html = function (html = '') {
5823
+ return decode_html(
5824
+ String(html)
5825
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
5826
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
5827
+ .replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ')
5828
+ .replace(/<svg[\s\S]*?<\/svg>/gi, ' ')
5829
+ .replace(/<[^>]+>/g, ' ')
5830
+ .replace(/\s+/g, ' ')
5831
+ .trim(),
5832
+ );
5833
+ };
5834
+
5835
+ const extract_title = function (html = '') {
5836
+ const match = String(html).match(/<title[^>]*>([\s\S]*?)<\/title>/i);
5837
+ return match ? decode_html(match[1]).replace(/\s+/g, ' ').trim() : '';
5838
+ };
5839
+
5840
+ const get_terms = function (question) {
5841
+ const terms = String(question)
5842
+ .toLowerCase()
5843
+ .replace(/[^a-z0-9\s-]/g, ' ')
5844
+ .split(/\s+/)
5845
+ .filter((term) => term.length > 2 && !['the', 'and', 'for', 'about', 'tell', 'with', 'from', 'what', 'who', 'how', 'xuda'].includes(term));
5846
+ if (/\baddress|where|location|located|office|contact\b/i.test(question)) {
5847
+ terms.push('address', 'contact', 'llc', 'boca', 'raton', 'florida');
5848
+ }
5849
+ return [...new Set(terms)];
5850
+ };
5851
+
5852
+ const score_page = function (page, terms, question) {
5853
+ const haystack = `${page.url} ${page.title} ${page.text}`.toLowerCase();
5854
+ let score = 0;
5855
+ for (const term of terms) {
5856
+ if (haystack.includes(term)) score += term.length > 4 ? 3 : 1;
5857
+ }
5858
+ if (/\b(company|about|xuda)\b/i.test(question) && new URL(page.url).pathname === '/') score += 20;
5859
+ if (/\bdocs?|guide|how|cli|dashboard|deploy|agent|app|vps|database|couchdb\b/i.test(question) && page.url.includes('/docs')) score += 8;
5860
+ if (/\bnetwork|ambassador|mentor|city|region\b/i.test(question) && page.url.includes('network')) score += 8;
5861
+ if (/\baddress|where|location|located|office|contact\b/i.test(question) && /\/legal\/privacy-policy/i.test(page.url)) score += 100;
5862
+ if (/\baddress|where|location|located|office|contact\b/i.test(question) && /\/legal\/terms|contact/i.test(page.url)) score += 30;
5863
+ return score;
5864
+ };
5865
+
5866
+ const make_snippet = function (text = '', terms = [], max_length = 3000) {
5867
+ const lower = text.toLowerCase();
5868
+ const anchors = ['address', 'contact us', 'xuda llc', 'boca raton', 'florida', ...terms];
5869
+ let index = -1;
5870
+ for (const anchor of anchors) {
5871
+ index = lower.indexOf(String(anchor).toLowerCase());
5872
+ if (index > -1) break;
5873
+ }
5874
+ if (index < 0) return text.slice(0, max_length);
5875
+ const start = Math.max(0, index - Math.floor(max_length / 3));
5876
+ const end = Math.min(text.length, start + max_length);
5877
+ return `${start > 0 ? '...' : ''}${text.slice(start, end)}${end < text.length ? '...' : ''}`;
5878
+ };
5879
+
5880
+ const extract_public_facts = function (page, question) {
5881
+ const facts = [];
5882
+ if (/\baddress|where|location|located|office|contact\b/i.test(question)) {
5883
+ const address_match = String(page.text || '').match(/Address:\s*([^|]+?)(?=\s+By using|\s+Privacy Policy|\s+Terms|\s*$)/i);
5884
+ if (address_match?.[1]) {
5885
+ facts.push({
5886
+ label: 'Address',
5887
+ value: address_match[1].replace(/\s+/g, ' ').trim(),
5888
+ url: page.url,
5889
+ });
5890
+ }
5891
+ }
5892
+ return facts;
5893
+ };
5894
+
5895
+ const fetch_text = async function (url, timeout_ms = 8000) {
5896
+ const controller = new AbortController();
5897
+ const timer = setTimeout(() => controller.abort(), timeout_ms);
5898
+ try {
5899
+ const res = await fetch(url, {
5900
+ signal: controller.signal,
5901
+ headers: { 'User-Agent': 'Xuda Ambassador Support Readonly Website Indexer' },
5902
+ });
5903
+ if (!res.ok) throw new Error(`${url} returned ${res.status}`);
5904
+ return await res.text();
5905
+ } finally {
5906
+ clearTimeout(timer);
5907
+ }
5908
+ };
5909
+
5910
+ const load_dump = async function () {
5911
+ try {
5912
+ const manifest = JSON.parse(await fs.promises.readFile(manifest_path, 'utf8'));
5913
+ if (Number(manifest.cache_version || 0) === cache_version && Date.now() - Number(manifest.created_ts || 0) < ttl_ms && Array.isArray(manifest.pages) && manifest.pages.length) {
5914
+ const pages = [];
5915
+ for (const page of manifest.pages) {
5916
+ try {
5917
+ pages.push(JSON.parse(await fs.promises.readFile(path.join(dump_dir, page.file), 'utf8')));
5918
+ } catch (_) {
5919
+ /* ignore corrupt cache item */
5920
+ }
5921
+ }
5922
+ if (pages.length) return { pages, from_cache: true };
5923
+ }
5924
+ } catch (_) {
5925
+ /* build cache below */
5926
+ }
5927
+
5928
+ await fs.promises.mkdir(dump_dir, { recursive: true });
5929
+ const sitemap_xml = await fetch_text(`${website_base}/sitemap.xml`, 8000);
5930
+ const priority_urls = [
5931
+ `${website_base}/`,
5932
+ `${website_base}/about`,
5933
+ `${website_base}/legal/privacy-policy`,
5934
+ `${website_base}/legal/terms`,
5935
+ `${website_base}/contact`,
5936
+ ];
5937
+ const sitemap_urls = [...sitemap_xml.matchAll(/<loc>([\s\S]*?)<\/loc>/gi)]
5938
+ .map((match) => decode_html(match[1]).trim())
5939
+ .filter((url) => {
5940
+ try {
5941
+ const parsed = new URL(url);
5942
+ return parsed.origin === website_base && !/\.(png|jpe?g|gif|webp|svg|pdf|zip)$/i.test(parsed.pathname);
5943
+ } catch (_) {
5944
+ return false;
5945
+ }
5946
+ })
5947
+ .slice(0, 120);
5948
+ const urls = [...new Set([...priority_urls, ...sitemap_urls])];
5949
+
5950
+ const pages = [];
5951
+ for (const url of urls) {
5952
+ try {
5953
+ const html = await fetch_text(url, 8000);
5954
+ const page = {
5955
+ url,
5956
+ title: extract_title(html),
5957
+ text: strip_html(html).slice(0, 80000),
5958
+ fetched_ts: Date.now(),
5959
+ };
5960
+ if (page.text.length < 80) continue;
5961
+ const file = `${crypto.createHash('sha1').update(url).digest('hex')}.json`;
5962
+ await fs.promises.writeFile(path.join(dump_dir, file), JSON.stringify(page, null, 2));
5963
+ pages.push({ ...page, file });
5964
+ } catch (err) {
5965
+ pages.push({ url, title: '', text: `Fetch failed: ${err.message || String(err)}`, fetch_error: true });
5966
+ }
5967
+ }
5968
+
5969
+ await fs.promises.writeFile(
5970
+ manifest_path,
5971
+ JSON.stringify(
5972
+ {
5973
+ source: website_origin,
5974
+ cache_version,
5975
+ created_ts: Date.now(),
5976
+ pages: pages.filter((page) => !page.fetch_error).map((page) => ({ url: page.url, file: page.file, title: page.title })),
5977
+ },
5978
+ null,
5979
+ 2,
5980
+ ),
5981
+ );
5982
+ return { pages, from_cache: false };
5983
+ };
5984
+
5985
+ tools.push(
5986
+ tool({
5987
+ name: (name || 'Xuda Ambassador Public Website Readonly').substring(0, 60),
5988
+ description: description || 'Readonly lookup against public xuda.ai website pages for ambassador support answers.',
5989
+ parameters: z.object({
5990
+ question: z.string().describe('The support question to answer from public xuda.ai website pages.'),
5991
+ }),
5992
+ async execute(e) {
5993
+ const question = String(e?.question || '').trim();
5994
+ if (!question) {
5995
+ return 'question is required';
5996
+ }
5997
+
5998
+ const run_lookup = async function () {
5999
+ const { pages } = await load_dump();
6000
+ const terms = get_terms(question);
6001
+ const ranked = pages
6002
+ .filter((page) => !page.fetch_error)
6003
+ .map((page) => ({ page, score: score_page(page, terms, question) }))
6004
+ .filter((item) => item.score > 0)
6005
+ .sort((a, b) => b.score - a.score)
6006
+ .slice(0, 5)
6007
+ .map((item) => ({
6008
+ score: item.score,
6009
+ url: item.page.url,
6010
+ title: item.page.title,
6011
+ snippet: make_snippet(item.page.text, terms),
6012
+ }));
6013
+ const facts = pages
6014
+ .filter((page) => !page.fetch_error)
6015
+ .flatMap((page) => extract_public_facts(page, question))
6016
+ .slice(0, 5);
6017
+
6018
+ return {
6019
+ source: website_origin,
6020
+ readonly: true,
6021
+ question,
6022
+ facts,
6023
+ result_count: ranked.length,
6024
+ results: ranked,
6025
+ instruction: ranked.length
6026
+ ? 'Answer only from these public xuda.ai website facts and snippets. If facts are present, use them as the primary source. Cite page URLs inline when useful. Do not mention internal cache, dump folders, or implementation details.'
6027
+ : 'No relevant public xuda.ai pages were found. Say that the public xuda.ai website did not contain enough information and ask for a narrower page or topic. Do not mention internal cache, dump folders, or implementation details.',
6028
+ };
6029
+ };
6030
+
6031
+ try {
6032
+ const data = await Promise.race([
6033
+ run_lookup(),
6034
+ new Promise((_, reject) => setTimeout(() => reject(new Error('xuda.ai website lookup timed out')), 20000)),
6035
+ ]);
6036
+ return JSON.stringify(data, null, 2);
6037
+ } catch (err) {
6038
+ return `xuda.ai website lookup failed: ${err.message || String(err)}`;
6039
+ }
6040
+ },
6041
+ }),
6042
+ );
6043
+ };
6044
+
5543
6045
  for (const [key, val] of Object.entries(ai_agent_doc?.agentConfig?.agent_tools || [])) {
5544
6046
  const { name = '', description = '' } = val;
5545
6047
  switch (val.type) {
6048
+ case 'xuda_network_ambassador_dev_couchdb_readonly': {
6049
+ add_xuda_public_website_tool({
6050
+ name: name || 'Xuda Ambassador Public Website Readonly',
6051
+ description: description || 'Readonly lookup against public xuda.ai website pages for ambassador support answers.',
6052
+ });
6053
+ break;
6054
+ }
6055
+
6056
+ case 'xuda_network_mentor_dev_server_readonly': {
6057
+ add_internal_codex_tool({
6058
+ name: name || 'Xuda Mentor Dev Server Readonly',
6059
+ description: description || 'Readonly Codex lookup on /var/xuda of the dev server for technical mentor answers.',
6060
+ system_prompt: `You are Codex helping a Xuda Network mentor answer technical questions about the Xuda codebase.
6061
+
6062
+ Scope:
6063
+ - You may inspect only the dev server ${_conf?.internal_ai_agents?.dev_server_host || 'dev.xuda.ai'}.
6064
+ - You may use SSH only for readonly inspection.
6065
+ - You may read only under /var/xuda.
6066
+ - Prefer safe inspection commands such as pwd, ls, find, rg, sed -n, head, tail, wc, git status, git diff --stat, git log --oneline, and git show.
6067
+
6068
+ Hard restrictions:
6069
+ - Never access config, config_dev, .env files, secret folders, SSH keys, certificates, credential files, database dumps, backups, logs likely to contain credentials, or any path outside /var/xuda.
6070
+ - Never query CouchDB or any database.
6071
+ - Never use network commands to call internal services.
6072
+ - Never run commands that write, mutate, install, delete, restart, deploy, publish, chmod/chown, kill processes, or change system state.
6073
+ - Never reveal passwords, tokens, API keys, private keys, names of private people, private emails, personal information, customer data, security details, or anything that could harm Xuda, its team, customers, or product security.
6074
+ - If the answer requires forbidden access, say it is outside the readonly mentor scope.
6075
+ - If sensitive data appears accidentally, do not quote it. State that sensitive output was omitted.`,
6076
+ });
6077
+ break;
6078
+ }
6079
+
5546
6080
  case 'web_search': {
5547
6081
  tools.push(webSearchTool());
5548
6082
  break;
@@ -5831,8 +6365,8 @@ const load_ai_agent_doc = async function (app_id, agent_id) {
5831
6365
  ai_agent_doc.reference_doc = reference_doc;
5832
6366
  } else if (ai_agent_doc.studio_meta.installed_from_app_uid) {
5833
6367
  const reference_doc = _.cloneDeep(ai_agent_doc);
5834
- await get_account_default_project_id(ai_agent_doc.studio_meta.installed_from_app_uid);
5835
- ai_agent_doc = await db_module.get_app_couch_doc_native(app_id, ai_agent_doc.studio_meta.share_item_id);
6368
+ const source_app_id = ai_agent_doc.studio_meta.installed_from_app_id || (await get_account_default_project_id(ai_agent_doc.studio_meta.installed_from_app_uid));
6369
+ ai_agent_doc = await db_module.get_app_couch_doc_native(source_app_id, ai_agent_doc.studio_meta.share_item_id || ai_agent_doc._id);
5836
6370
  ai_agent_doc.reference_doc = reference_doc;
5837
6371
  }
5838
6372
 
@@ -6642,18 +7176,14 @@ const auto_response = async function (uid, profile_id, contact_id, conversation_
6642
7176
  // const conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_id);
6643
7177
 
6644
7178
  let auto_respond_agents = account_profile_doc.auto_respond_agents;
6645
- if (auto_respond_agents === null || typeof auto_respond_agents === 'undefined') return;
6646
-
6647
- if (!Array.isArray(auto_respond_agents)) {
6648
- auto_respond_agents = [];
6649
- }
7179
+ if (!Array.isArray(auto_respond_agents)) auto_respond_agents = [];
6650
7180
 
6651
7181
  if (!auto_respond_agents.length) {
6652
7182
  const ai_agents_ret = await get_user_ai_agents(uid);
6653
7183
  auto_respond_agents = ai_agents_ret.docs.map((doc) => doc._id).filter(Boolean);
6654
7184
  }
6655
7185
 
6656
- if (!auto_respond_agents.length) return;
7186
+ const use_default_agent = auto_respond_agents.length === 0;
6657
7187
 
6658
7188
  const runner = new Runner();
6659
7189
  const app_obj = await get_app_obj(account_profile_info.app_id);
@@ -6681,6 +7211,24 @@ const auto_response = async function (uid, profile_id, contact_id, conversation_
6681
7211
  let agents = [];
6682
7212
  let model = _conf.default_ai_model;
6683
7213
 
7214
+ if (use_default_agent) {
7215
+ agents.push(
7216
+ new Agent({
7217
+ name: 'Auto Reply',
7218
+ instructions: `You are ${account_profile_doc.profile_name || userName}'s automatic ${conversation_type === 'chat' ? 'chat' : 'email'} assistant.
7219
+ Reply to the latest incoming message from ${contact_doc.name || contact_doc.email} in a friendly, helpful, and concise way.
7220
+ Use the conversation history for context.
7221
+ Match the language the contact wrote in.
7222
+ Do not mention that the reply is automated.
7223
+ ${conversation_type === 'chat' ? 'Return only the chat message text, ready to send.' : 'Return only the email body, ready to send.'}
7224
+ If a signature is available, append it at the end:
7225
+ ${account_profile_doc.profile_signature || ''}`.trim(),
7226
+ model,
7227
+ metadata: { auto_response: true, default: true, ts: Date.now() },
7228
+ }),
7229
+ );
7230
+ }
7231
+
6684
7232
  for (const agent_id of auto_respond_agents) {
6685
7233
  try {
6686
7234
  const ai_agent_doc = await load_ai_agent_doc(account_profile_info.app_id, agent_id);
@@ -6773,7 +7321,36 @@ Return only the email body.`;
6773
7321
  const response_text = output?.state?._currentStep?.output?.trim();
6774
7322
  if (!response_text) return;
6775
7323
 
6776
- if (conversation_type === 'chat') {
7324
+ const is_widget_reply = contact_doc.source === 'widget';
7325
+
7326
+ if (conversation_type === 'chat' && is_widget_reply) {
7327
+ // Widget visitor has no project — write the reply directly on the rep's app only.
7328
+ let conv_item_ref_id;
7329
+ try {
7330
+ conv_item_ref_id = await add_conversation_item(uid, profile_id, conversation_doc._id, response_text, 'chat', contact_id, { direction: 'out' });
7331
+ } catch (err) {}
7332
+ const now = Date.now();
7333
+ const reply_item = {
7334
+ _id: await _common.xuda_get_uuid('chat_conversation_item'),
7335
+ stat: 3,
7336
+ docType: 'chat_conversation_item',
7337
+ uid,
7338
+ conversation_type: 'chat',
7339
+ type: 'chat',
7340
+ date_created_ts: now,
7341
+ ts: now,
7342
+ conversation_id: conversation_doc._id,
7343
+ text: response_text,
7344
+ reference_id: contact_id,
7345
+ conversation_item_reference_id: conv_item_ref_id,
7346
+ direction: 'out',
7347
+ role: 'user',
7348
+ auto_response: true,
7349
+ widget_source: true,
7350
+ rtl: _common.detectRTL(response_text),
7351
+ };
7352
+ await db_module.save_app_couch_doc(account_profile_info.app_id, reply_item);
7353
+ } else if (conversation_type === 'chat') {
6777
7354
  await contact_chat_conversation(
6778
7355
  {
6779
7356
  profile_id,
@@ -7161,10 +7738,10 @@ const get_plugin_tool = async function (app_id, uid, plugin_name, method, prop_d
7161
7738
  }
7162
7739
  };
7163
7740
 
7164
- 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) {
7741
+ 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) {
7165
7742
  try {
7166
7743
  // 1. Generate the images
7167
- let result = await create_image(uid, prompt, undefined, undefined, numGenerations, undefined, undefined, { drive_type, file_path, file_name }, account_profile_info);
7744
+ let result = await create_image(uid, prompt, undefined, undefined, numGenerations, width, height, { drive_type, file_path, file_name }, account_profile_info);
7168
7745
 
7169
7746
  // Normalize to array
7170
7747
  const images = Array.isArray(result) ? result : [result];
@@ -8005,12 +8582,12 @@ const create_ai_agent_image = async function (req, job_id, headers) {
8005
8582
  const tempOutputPath = path.join(tempDir, `output_${uniqueId}.png`);
8006
8583
 
8007
8584
  const { app_id, uid, ai_agent_id, tags } = req;
8008
- const account_profile_info = await get_active_account_profile_info(uid);
8585
+ const account_profile_info = req.account_profile_info || (await get_active_account_profile_info(uid));
8009
8586
  const ai_agent_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, ai_agent_id);
8010
8587
  const account_doc = await db_module.get_couch_doc_native('xuda_accounts', uid);
8011
8588
 
8012
8589
  // faceless humanoid for business
8013
- if (account_doc.account_info.account_type === 'business') {
8590
+ if (account_doc.account_info.account_type === 'business' || ai_agent_doc.studio_meta?.force_faceless_agent_image) {
8014
8591
  const prompt = `
8015
8592
  Create a futuristic, faceless humanoid profile portrait with a transparent background.
8016
8593
 
@@ -8045,7 +8622,7 @@ const create_ai_agent_image = async function (req, job_id, headers) {
8045
8622
 
8046
8623
  `;
8047
8624
 
8048
- const images_arr = await create_and_upload_image_to_drive('studio', 'Progs Thumbnails', ai_agent_id, prompt, 1, uid, app_id, job_id, headers, false, tags, account_profile_info);
8625
+ const images_arr = await create_and_upload_image_to_drive('studio', 'Progs Thumbnails', ai_agent_id, prompt, 1, uid, app_id, job_id, headers, false, tags, account_profile_info, 1024, 1024);
8049
8626
  return { code: 1, data: images_arr[0] };
8050
8627
  }
8051
8628
 
@@ -13293,8 +13870,8 @@ export const get_chat_suggestions = async function (req) {
13293
13870
  ai_agent_doc.reference_doc = reference_doc;
13294
13871
  } else if (ai_agent_doc.studio_meta.installed_from_app_uid) {
13295
13872
  const reference_doc = _.cloneDeep(ai_agent_doc);
13296
- await get_account_default_project_id(ai_agent_doc.studio_meta.installed_from_app_uid);
13297
- ai_agent_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, ai_agent_doc.studio_meta.share_item_id);
13873
+ const source_app_id = ai_agent_doc.studio_meta.installed_from_app_id || (await get_account_default_project_id(ai_agent_doc.studio_meta.installed_from_app_uid));
13874
+ ai_agent_doc = await db_module.get_app_couch_doc_native(source_app_id, ai_agent_doc.studio_meta.share_item_id || ai_agent_doc._id);
13298
13875
  ai_agent_doc.reference_doc = reference_doc;
13299
13876
  }
13300
13877
 
@@ -13453,3 +14030,513 @@ export const validate_credits_limit = async function (uid, profile_id) {
13453
14030
  return err;
13454
14031
  }
13455
14032
  };
14033
+
14034
+ ////////////////////////////// CHAT WIDGET (embed) //////////////////////////////
14035
+
14036
+ const WIDGET_EMAIL_RE = /^[\w\.\-+]+@[a-zA-Z\d\.\-]+\.[a-zA-Z]{2,}$/;
14037
+ const WIDGET_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
14038
+ const WIDGET_DEFAULT_GREETING = 'Hi! How can we help?';
14039
+
14040
+ const _widget_split_name = function (name) {
14041
+ const parts = String(name || '')
14042
+ .trim()
14043
+ .split(/\s+/);
14044
+ return { first_name: parts[0] || name || '', last_name: parts.slice(1).join(' ') || '' };
14045
+ };
14046
+
14047
+ const _widget_verify_google_id_token = async function (id_token) {
14048
+ // Use Google's tokeninfo endpoint — validates signature, expiration, and returns the payload.
14049
+ // Cheaper than carrying google-auth-library; one extra HTTPS round-trip per signup is fine.
14050
+ const r = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(id_token)}`);
14051
+ if (!r.ok) throw new Error('google_token_invalid');
14052
+ const payload = await r.json();
14053
+ if (!payload || !payload.email) throw new Error('google_token_no_email');
14054
+ if (payload.email_verified !== 'true' && payload.email_verified !== true) throw new Error('google_email_unverified');
14055
+ const expected_aud = _conf?.gmail?.clientId;
14056
+ if (expected_aud && payload.aud !== expected_aud) throw new Error('google_token_wrong_aud');
14057
+ return payload;
14058
+ };
14059
+
14060
+ const _widget_find_or_create_visitor = async function (email, name, google_sub) {
14061
+ const ret = await db_module.find_couch_query('xuda_accounts', {
14062
+ selector: { 'account_info.email': email },
14063
+ fields: ['_id'],
14064
+ limit: 1,
14065
+ });
14066
+ if (ret.docs && ret.docs.length) {
14067
+ // If we have a google_sub and the existing account lacks one, attach it.
14068
+ if (google_sub) {
14069
+ try {
14070
+ const acct_ret = await db_module.get_couch_doc('xuda_accounts', ret.docs[0]._id);
14071
+ if (acct_ret.code > -1) {
14072
+ const acct = acct_ret.data;
14073
+ if (!acct.google_sub) {
14074
+ acct.google_sub = google_sub;
14075
+ acct.google_linked = true;
14076
+ acct.ts = Date.now();
14077
+ await db_module.save_couch_doc('xuda_accounts', acct);
14078
+ }
14079
+ }
14080
+ } catch (err) {}
14081
+ }
14082
+ return ret.docs[0]._id;
14083
+ }
14084
+
14085
+ const _utils = await import(path.join(process.env.XUDA_HOME, 'common', 'xuda-cpi-utils.mjs'));
14086
+ const secret = _utils.get_id('tmp_pass', 5);
14087
+ const hash = _utils.hash(secret);
14088
+ const { first_name, last_name } = _widget_split_name(name);
14089
+ const doc = {
14090
+ _id: await _common.xuda_get_uuid('account'),
14091
+ stat: 1,
14092
+ docType: 'account',
14093
+ source: 'widget',
14094
+ password: hash,
14095
+ email,
14096
+ account_info: { email, username: _utils.get_id('xu'), first_name, last_name, full_name: name },
14097
+ };
14098
+ if (google_sub) {
14099
+ doc.google_sub = google_sub;
14100
+ doc.google_linked = true;
14101
+ }
14102
+ const save_ret = await db_module.save_couch_doc('xuda_accounts', doc);
14103
+ if (save_ret.code < 0) throw new Error(save_ret.data || 'visitor_create_failed');
14104
+ return doc._id;
14105
+ };
14106
+
14107
+ const _widget_find_existing_team_req = async function (visitor_uid, canonical_profile_id) {
14108
+ const ret = await db_module.find_couch_query('xuda_team', {
14109
+ selector: {
14110
+ docType: 'team_request',
14111
+ access_type: 'contact_connection',
14112
+ team_req_from_uid: visitor_uid,
14113
+ share_item_id: canonical_profile_id,
14114
+ contact_source: 'widget',
14115
+ },
14116
+ limit: 1,
14117
+ });
14118
+ return ret?.docs?.[0] || null;
14119
+ };
14120
+
14121
+ const _widget_pick_assignee = async function (canonical_owner_uid, canonical_profile_id) {
14122
+ const member_uids = await account_ms.get_account_profile_group_member_uids(canonical_owner_uid, canonical_profile_id);
14123
+ const candidates = [canonical_owner_uid, ...(member_uids || [])].filter(Boolean);
14124
+ return candidates[Math.floor(Math.random() * candidates.length)];
14125
+ };
14126
+
14127
+ const _widget_auto_accept = async function (team_req_doc, assigned_uid, visitor_email, visitor_name) {
14128
+ // Add contact first (creates the contact on the rep's side). The team_req on disk
14129
+ // is still stat=2 at this point, which is fine because add_contact only stores a
14130
+ // reference (team_req_id) on the contact doc and does not read the team_req body.
14131
+ const add_ret = await account_ms.add_contact({
14132
+ uid: assigned_uid,
14133
+ name: visitor_name,
14134
+ email: visitor_email,
14135
+ contact_uid: team_req_doc.team_req_from_uid,
14136
+ source: 'widget',
14137
+ team_req_id: team_req_doc._id,
14138
+ });
14139
+ let contact_id;
14140
+ if (add_ret.code > -1) {
14141
+ contact_id = add_ret.data?.id;
14142
+ } else if (add_ret.contact_id) {
14143
+ contact_id = add_ret.contact_id;
14144
+ }
14145
+ if (!contact_id) throw new Error('add_contact_failed');
14146
+
14147
+ // Refresh _rev and write the final accepted state in one update.
14148
+ const fresh_ret = await db_module.get_couch_doc('xuda_team', team_req_doc._id);
14149
+ if (fresh_ret.code < 0) throw new Error(fresh_ret.data || 'team_req_missing');
14150
+ const fresh = fresh_ret.data;
14151
+ fresh.team_req_stat = 3;
14152
+ fresh.team_req_stat_desc = 'widget auto-accepted';
14153
+ fresh.team_req_status_date = Date.now();
14154
+ fresh.team_req_to_contact_id = contact_id;
14155
+ const save_ret = await db_module.save_couch_doc('xuda_team', fresh);
14156
+ if (save_ret.code < 0) throw new Error(save_ret.data || 'team_req_save_failed');
14157
+
14158
+ team_req_doc.team_req_stat = 3;
14159
+ team_req_doc.team_req_to_contact_id = contact_id;
14160
+ return contact_id;
14161
+ };
14162
+
14163
+ const _widget_ensure_conversation = async function (assigned_uid, canonical_profile_id, contact_id, job_id, headers) {
14164
+ const rep_app_id = await account_ms.get_account_default_project_id(assigned_uid);
14165
+ const conv_ret = await db_module.find_app_couch_query(rep_app_id, {
14166
+ selector: {
14167
+ docType: 'chat_conversation',
14168
+ conversation_type: 'chat',
14169
+ reference_type: 'contacts',
14170
+ reference_id: contact_id,
14171
+ stat: { $lt: 4 },
14172
+ },
14173
+ limit: 1,
14174
+ sort: [{ ts: 'desc' }],
14175
+ });
14176
+ if (conv_ret?.docs?.length) return conv_ret.docs[0]._id;
14177
+
14178
+ const save_ret = await create_conversation(
14179
+ {
14180
+ uid: assigned_uid,
14181
+ profile_id: canonical_profile_id,
14182
+ conversation_type: 'chat',
14183
+ reference_type: 'contacts',
14184
+ reference_id: contact_id,
14185
+ prompt: '',
14186
+ direction: 'in',
14187
+ perform_ai_execution: false,
14188
+ },
14189
+ job_id,
14190
+ headers,
14191
+ );
14192
+ return save_ret?.data?.id;
14193
+ };
14194
+
14195
+ const _widget_load_session = async function (widget_token) {
14196
+ if (!widget_token) return null;
14197
+ const ret = await db_module.get_couch_doc('xuda_sessions', widget_token);
14198
+ if (ret.code < 0) return null;
14199
+ const session = ret.data;
14200
+ if (session.docType !== 'widget_session') return null;
14201
+ if (session.stat !== 2) return null;
14202
+ if (session.ttl_ts && session.ttl_ts < Date.now()) return null;
14203
+ return session;
14204
+ };
14205
+
14206
+ const _widget_refresh_state = async function (session, job_id, headers) {
14207
+ if (session.state === 'open' || session.state === 'declined') return session;
14208
+ const tr_ret = await db_module.get_couch_doc('xuda_team', session.team_req_id);
14209
+ if (tr_ret.code < 0) return session;
14210
+ const tr = tr_ret.data;
14211
+ if (tr.team_req_stat === 3) {
14212
+ session.state = 'open';
14213
+ session.contact_id = tr.team_req_to_contact_id || session.contact_id;
14214
+ if (!session.conversation_id && session.contact_id) {
14215
+ try {
14216
+ session.conversation_id = await _widget_ensure_conversation(session.assigned_uid, session.profile_id, session.contact_id, job_id, headers);
14217
+ } catch (err) {}
14218
+ }
14219
+ session.ts = Date.now();
14220
+ await db_module.save_couch_doc('xuda_sessions', session);
14221
+ } else if (tr.team_req_stat === 4) {
14222
+ session.state = 'declined';
14223
+ session.ts = Date.now();
14224
+ await db_module.save_couch_doc('xuda_sessions', session);
14225
+ }
14226
+ return session;
14227
+ };
14228
+
14229
+ export const get_widget_embed_snippet = async function (req, job_id, headers) {
14230
+ try {
14231
+ const { uid, profile_id, config } = req;
14232
+ const account_profile_info = await get_active_account_profile_info(uid, profile_id);
14233
+ const canonical_owner_uid = account_profile_info.uid;
14234
+ const canonical_profile_id = account_profile_info.account_profile_id;
14235
+
14236
+ // Persist widget config + enable only when caller is canonical owner
14237
+ if (canonical_owner_uid === uid) {
14238
+ const doc = account_profile_info.account_profile_obj || { _id: canonical_profile_id };
14239
+ doc.widget_enabled = true;
14240
+ doc.widget_config = { ...(doc.widget_config || {}), ...(config || {}) };
14241
+ doc.ts = Date.now();
14242
+ await db_module.save_app_couch_doc_native(account_profile_info.app_id, doc);
14243
+ }
14244
+
14245
+ const widget_profile_id = `${canonical_owner_uid}.${canonical_profile_id}`;
14246
+ const domain = _conf.domain || 'xuda.io';
14247
+ const iframe_url = `https://${domain}/chat-widget/${widget_profile_id}`;
14248
+ const snippet = `<script>(function(){var d=document,s=d.createElement('script');s.src='https://${domain}/dist/runtime/js/chat-widget-loader.js';s.async=1;s.setAttribute('data-profile','${widget_profile_id}');d.head.appendChild(s);})();</script>`;
14249
+
14250
+ return { code: 1, data: { widget_profile_id, snippet, iframe_url } };
14251
+ } catch (err) {
14252
+ return { code: -1, data: err.message || String(err) };
14253
+ }
14254
+ };
14255
+
14256
+ const _widget_signup_core = async function (req, job_id, headers, opts) {
14257
+ const { profile_id, email, name, widget_origin, google_sub, signup_method } = req;
14258
+ const [owner_uid_part, pid_part] = String(profile_id || '').split('.');
14259
+ if (!owner_uid_part || !pid_part) return { code: -404, data: 'invalid_profile' };
14260
+
14261
+ const account_profile_info = await get_active_account_profile_info(owner_uid_part, pid_part);
14262
+ const canonical_owner_uid = account_profile_info.uid;
14263
+ const canonical_profile_id = account_profile_info.account_profile_id;
14264
+
14265
+ const ap_doc = account_profile_info.account_profile_obj || {};
14266
+ if (ap_doc.widget_enabled === false) return { code: -404, data: 'widget_disabled' };
14267
+
14268
+ const visitor_uid = await _widget_find_or_create_visitor(email, name, google_sub);
14269
+ return await _widget_signup_finalize(visitor_uid, email, name, widget_origin, signup_method, account_profile_info, canonical_owner_uid, canonical_profile_id, ap_doc, job_id, headers);
14270
+ };
14271
+
14272
+ export const widget_signup = async function (req, job_id, headers) {
14273
+ try {
14274
+ let { profile_id, email, name, widget_origin } = req;
14275
+ if (typeof email !== 'string' || typeof name !== 'string') return { code: -1, data: 'invalid_input' };
14276
+ email = email.toLowerCase().trim();
14277
+ name = name.trim();
14278
+ if (!email || !name) return { code: -1, data: 'invalid_input' };
14279
+ if (!WIDGET_EMAIL_RE.test(email)) return { code: -1, data: 'invalid_email' };
14280
+
14281
+ return await _widget_signup_core({ profile_id, email, name, widget_origin, signup_method: 'email' }, job_id, headers);
14282
+ } catch (err) {
14283
+ return { code: -1, data: err.message || String(err) };
14284
+ }
14285
+ };
14286
+
14287
+ export const widget_signup_google = async function (req, job_id, headers) {
14288
+ try {
14289
+ const { profile_id, id_token, widget_origin } = req;
14290
+ if (!id_token || typeof id_token !== 'string') return { code: -1, data: 'missing_id_token' };
14291
+
14292
+ let payload;
14293
+ try {
14294
+ payload = await _widget_verify_google_id_token(id_token);
14295
+ } catch (err) {
14296
+ return { code: -401, data: err.message || 'google_token_invalid' };
14297
+ }
14298
+
14299
+ const email = String(payload.email || '')
14300
+ .toLowerCase()
14301
+ .trim();
14302
+ const name = String(payload.name || payload.email?.split('@')[0] || 'Friend').trim();
14303
+ if (!email) return { code: -1, data: 'no_email_in_token' };
14304
+
14305
+ return await _widget_signup_core({ profile_id, email, name, widget_origin, google_sub: payload.sub, signup_method: 'google' }, job_id, headers);
14306
+ } catch (err) {
14307
+ return { code: -1, data: err.message || String(err) };
14308
+ }
14309
+ };
14310
+
14311
+ const _widget_signup_finalize = async function (visitor_uid, email, name, widget_origin, signup_method, account_profile_info, canonical_owner_uid, canonical_profile_id, ap_doc, job_id, headers) {
14312
+ try {
14313
+ let team_req_doc = await _widget_find_existing_team_req(visitor_uid, canonical_profile_id);
14314
+ let assigned_uid;
14315
+
14316
+ if (team_req_doc) {
14317
+ assigned_uid = team_req_doc.team_req_to_uid;
14318
+ } else {
14319
+ assigned_uid = await _widget_pick_assignee(canonical_owner_uid, canonical_profile_id);
14320
+ if (!assigned_uid) return { code: -1, data: 'no_assignee' };
14321
+
14322
+ const assigned_acct_ret = await db_module.get_couch_doc('xuda_accounts', assigned_uid);
14323
+ const assigned_email = assigned_acct_ret?.data?.account_info?.email || assigned_acct_ret?.data?.email || '';
14324
+
14325
+ const { first_name, last_name } = _widget_split_name(name);
14326
+ const team_req_id = await _common.xuda_get_uuid('team_req');
14327
+ team_req_doc = {
14328
+ _id: team_req_id,
14329
+ team_req_id,
14330
+ team_req_date: Date.now(),
14331
+ team_req_status_date: null,
14332
+ team_req_to_email: assigned_email,
14333
+ team_req_to_uid: assigned_uid,
14334
+ team_req_stat: 2,
14335
+ team_req_stat_desc: 'widget signup',
14336
+ team_req_from_uid: visitor_uid,
14337
+ docType: 'team_request',
14338
+ team_req_app_id: null,
14339
+ access_type: 'contact_connection',
14340
+ uid: visitor_uid,
14341
+ contact_source: 'widget',
14342
+ share_item_id: canonical_profile_id,
14343
+ widget_origin: widget_origin || '',
14344
+ sender_account_info: { email, first_name, last_name, full_name: name },
14345
+ };
14346
+ const tr_save = await db_module.save_couch_doc('xuda_team', team_req_doc);
14347
+ if (tr_save.code < 0) return { code: -1, data: tr_save.data || 'team_request_create_failed' };
14348
+
14349
+ // fire and forget — sending the invitation email shouldn't block the visitor's response
14350
+ team_ms.send_access_invitation({ team_req_id: team_req_doc._id }).catch(() => {});
14351
+ }
14352
+
14353
+ let state = team_req_doc.team_req_stat === 3 ? 'open' : team_req_doc.team_req_stat === 4 ? 'declined' : 'pending';
14354
+ let contact_id = team_req_doc.team_req_to_contact_id;
14355
+ let conversation_id;
14356
+
14357
+ const auto_accept = !!ap_doc?.auto_respond;
14358
+ if (state === 'pending' && auto_accept) {
14359
+ try {
14360
+ contact_id = await _widget_auto_accept(team_req_doc, assigned_uid, email, name);
14361
+ state = 'open';
14362
+ } catch (err) {
14363
+ state = 'pending';
14364
+ }
14365
+ }
14366
+
14367
+ if (state === 'open' && contact_id) {
14368
+ try {
14369
+ conversation_id = await _widget_ensure_conversation(assigned_uid, canonical_profile_id, contact_id, job_id, headers);
14370
+ } catch (err) {}
14371
+ }
14372
+
14373
+ const widget_token = `wds_${crypto.randomUUID().replace(/-/g, '')}`;
14374
+ const now = Date.now();
14375
+ const session_doc = {
14376
+ _id: widget_token,
14377
+ docType: 'widget_session',
14378
+ stat: 2,
14379
+ state,
14380
+ visitor_uid,
14381
+ team_req_id: team_req_doc._id,
14382
+ assigned_uid,
14383
+ owner_uid: canonical_owner_uid,
14384
+ profile_id: canonical_profile_id,
14385
+ contact_id,
14386
+ conversation_id,
14387
+ widget_origin: widget_origin || '',
14388
+ date_created_ts: now,
14389
+ ts: now,
14390
+ ttl_ts: now + WIDGET_SESSION_TTL_MS,
14391
+ };
14392
+ await db_module.save_couch_doc('xuda_sessions', session_doc);
14393
+
14394
+ const assigned_info_ret = await account_ms.get_account_name({ uid_query: assigned_uid });
14395
+ const assigned_name = assigned_info_ret?.data?.full_name || assigned_info_ret?.data?.first_name || '';
14396
+ const assigned_avatar = assigned_info_ret?.data?.profile_picture || assigned_info_ret?.data?.profile_avatar || '';
14397
+ const greeting = ap_doc?.widget_config?.greeting || WIDGET_DEFAULT_GREETING;
14398
+
14399
+ return {
14400
+ code: 1,
14401
+ data: {
14402
+ widget_token,
14403
+ state,
14404
+ assigned_name,
14405
+ assigned_avatar,
14406
+ greeting,
14407
+ conversation_id,
14408
+ signup_method: signup_method || 'email',
14409
+ account_active: signup_method === 'google',
14410
+ signin_url: signup_method === 'google' ? `https://${_conf.domain || 'xuda.ai'}/dashboard/login` : undefined,
14411
+ },
14412
+ };
14413
+ } catch (err) {
14414
+ return { code: -1, data: err.message || String(err) };
14415
+ }
14416
+ };
14417
+
14418
+ export const widget_send_message = async function (req, job_id, headers) {
14419
+ try {
14420
+ const { widget_token, prompt = '', attachments = [] } = req;
14421
+ const text = typeof prompt === 'string' ? prompt : '';
14422
+ if (!text.trim() && (!Array.isArray(attachments) || !attachments.length)) {
14423
+ return { code: -1, data: 'invalid_prompt' };
14424
+ }
14425
+
14426
+ // Validate + sanitize image attachments. Inline base64 images only, max 5, ~2.5MB each.
14427
+ const safe_attachments = [];
14428
+ for (const att of (Array.isArray(attachments) ? attachments : []).slice(0, 5)) {
14429
+ if (!att || typeof att.data_uri !== 'string') continue;
14430
+ if (!/^data:image\/(png|jpeg|jpg|gif|webp);base64,/i.test(att.data_uri)) continue;
14431
+ if (att.data_uri.length > 3_500_000) continue;
14432
+ safe_attachments.push({
14433
+ data_uri: att.data_uri,
14434
+ filename: String(att.filename || 'image').slice(0, 200),
14435
+ size: Number(att.size) || 0,
14436
+ type: String(att.type || 'image/png').slice(0, 60),
14437
+ });
14438
+ }
14439
+
14440
+ let session = await _widget_load_session(widget_token);
14441
+ if (!session) return { code: -401, data: 'invalid_token' };
14442
+
14443
+ session = await _widget_refresh_state(session, job_id, headers);
14444
+ if (session.state !== 'open') return { code: -409, data: 'pending_approval' };
14445
+
14446
+ const { assigned_uid, profile_id, conversation_id, contact_id, widget_origin } = session;
14447
+ if (!conversation_id || !contact_id) return { code: -409, data: 'not_ready' };
14448
+
14449
+ const rep_app_id = await account_ms.get_account_default_project_id(assigned_uid);
14450
+
14451
+ // Compose the prompt text to include attachment notice (visible to the agent + history)
14452
+ let prompt_for_history = text.trim();
14453
+ if (safe_attachments.length) {
14454
+ const att_note = `[Attached ${safe_attachments.length} image${safe_attachments.length > 1 ? 's' : ''}: ${safe_attachments.map((a) => a.filename).join(', ')}]`;
14455
+ prompt_for_history = prompt_for_history ? `${prompt_for_history}\n\n${att_note}` : att_note;
14456
+ }
14457
+
14458
+ let conversation_item_reference_id;
14459
+ try {
14460
+ conversation_item_reference_id = await add_conversation_item(assigned_uid, profile_id, conversation_id, prompt_for_history, 'chat', contact_id, { direction: 'in' });
14461
+ report_ai_status('conversations');
14462
+ } catch (err) {
14463
+ report_ai_status('conversations', err);
14464
+ }
14465
+
14466
+ const now = Date.now();
14467
+ const item_doc = {
14468
+ _id: await _common.xuda_get_uuid('chat_conversation_item'),
14469
+ stat: 3,
14470
+ docType: 'chat_conversation_item',
14471
+ uid: assigned_uid,
14472
+ conversation_type: 'chat',
14473
+ type: 'chat',
14474
+ date_created_ts: now,
14475
+ ts: now,
14476
+ conversation_id,
14477
+ text: text.trim(),
14478
+ reference_id: contact_id,
14479
+ conversation_item_reference_id,
14480
+ direction: 'in',
14481
+ role: 'user',
14482
+ widget_source: true,
14483
+ widget_origin: widget_origin || '',
14484
+ rtl: _common.detectRTL(text),
14485
+ attachments: safe_attachments,
14486
+ };
14487
+ const save_ret = await db_module.save_app_couch_doc(rep_app_id, item_doc);
14488
+
14489
+ session.ts = now;
14490
+ await db_module.save_couch_doc('xuda_sessions', session);
14491
+
14492
+ try {
14493
+ auto_response(assigned_uid, profile_id, contact_id, 'chat');
14494
+ } catch (err) {}
14495
+
14496
+ return { code: 15, data: save_ret };
14497
+ } catch (err) {
14498
+ return { code: -1, data: err.message || String(err) };
14499
+ }
14500
+ };
14501
+
14502
+ export const widget_get_messages = async function (req, job_id, headers) {
14503
+ try {
14504
+ const { widget_token, since_ts = 0, limit = 100 } = req;
14505
+ let session = await _widget_load_session(widget_token);
14506
+ if (!session) return { code: -401, data: 'invalid_token' };
14507
+
14508
+ session = await _widget_refresh_state(session, job_id, headers);
14509
+
14510
+ const server_ts = Date.now();
14511
+ if (session.state !== 'open' || !session.conversation_id) {
14512
+ return { code: 15, data: { state: session.state, items: [], conversation_id: session.conversation_id || null, server_ts } };
14513
+ }
14514
+
14515
+ const rep_app_id = await account_ms.get_account_default_project_id(session.assigned_uid);
14516
+ const ret = await db_module.find_app_couch_query(rep_app_id, {
14517
+ selector: {
14518
+ docType: 'chat_conversation_item',
14519
+ conversation_id: session.conversation_id,
14520
+ stat: 3,
14521
+ date_created_ts: { $gt: Number(since_ts) || 0 },
14522
+ },
14523
+ sort: [{ date_created_ts: 'asc' }],
14524
+ limit: Math.min(Number(limit) || 100, 500),
14525
+ });
14526
+
14527
+ const items = (ret?.docs || []).map((d) => ({
14528
+ _id: d._id,
14529
+ text: d.text,
14530
+ direction: d.direction,
14531
+ role: d.role,
14532
+ date_created_ts: d.date_created_ts,
14533
+ conversation_type: d.conversation_type,
14534
+ attachments: d.attachments,
14535
+ rtl: d.rtl,
14536
+ }));
14537
+
14538
+ return { code: 15, data: { state: 'open', items, conversation_id: session.conversation_id, server_ts } };
14539
+ } catch (err) {
14540
+ return { code: -1, data: err.message || String(err) };
14541
+ }
14542
+ };