@xuda.io/ai_module 1.1.5615 → 1.1.5617

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
@@ -170,10 +170,14 @@ const get_openai_codex_usage_model = function (codex_model, requested_codex_mode
170
170
  return _conf.ai_models?.openai_codex_model ? 'openai_codex_model' : codex_model || _conf.openai_codex_command || 'openai_codex_cli';
171
171
  };
172
172
 
173
- const get_openai_codex_exec_args = function (codex_model) {
173
+ const get_openai_codex_exec_args = function (codex_model, opts = {}) {
174
174
  const args = ['exec', '--json'];
175
- const bypass_approvals_and_sandbox = typeof _conf.openai_codex_bypass_approvals_and_sandbox === 'undefined' ? true : _conf.openai_codex_bypass_approvals_and_sandbox;
176
- const sandbox = _conf.openai_codex_sandbox;
175
+ // A per-call sandbox (e.g. the AI site generator passes 'workspace-write')
176
+ // forces a confined run regardless of the global bypass default, so untrusted
177
+ // end-user prompts can't run Codex with full host access. The studio/vibe flow
178
+ // passes nothing and keeps the global setting.
179
+ const sandbox = opts.sandbox || _conf.openai_codex_sandbox;
180
+ const bypass_approvals_and_sandbox = opts.sandbox ? false : typeof _conf.openai_codex_bypass_approvals_and_sandbox === 'undefined' ? true : _conf.openai_codex_bypass_approvals_and_sandbox;
177
181
 
178
182
  if (codex_model) {
179
183
  args.push('--model', codex_model);
@@ -182,7 +186,10 @@ const get_openai_codex_exec_args = function (codex_model) {
182
186
  if (bypass_approvals_and_sandbox) {
183
187
  args.push('--dangerously-bypass-approvals-and-sandbox');
184
188
  } else if (sandbox) {
185
- args.push('--sandbox', sandbox);
189
+ // --skip-git-repo-check: draft folders aren't git repos; without it codex
190
+ // refuses to run sandboxed ("not inside a trusted directory"). The bypass
191
+ // path skipped this implicitly.
192
+ args.push('--sandbox', sandbox, '--skip-git-repo-check');
186
193
  }
187
194
 
188
195
  return args;
@@ -228,6 +235,7 @@ const drive_ms = await import(`${module_path}/drive_module/index_ms.mjs`);
228
235
  const jobs_ms = await import(`${module_path}/jobs_module/index_ms.mjs`);
229
236
  const team_ms = await import(`${module_path}/team_module/index_ms.mjs`);
230
237
  const email_ms = await import(`${module_path}/email_module/index_ms.mjs`);
238
+ const api_ms = await import(`${module_path}/api_module/index_ms.mjs`);
231
239
 
232
240
  const ws_dashboard_msa = await import(`${module_path}/ws_dashboard_module/index_msa.mjs`);
233
241
  const account_msa = await import(`${module_path}/account_module/index_msa.mjs`);
@@ -240,6 +248,25 @@ const report_ai_status = function (model, err) {
240
248
  if (err) console.error(err);
241
249
  };
242
250
 
251
+ // Full Stack VPS AI instructions. Read ONCE from disk on the first AI call that
252
+ // uses a full_stack_vps tool, then cached in memory for the life of the process.
253
+ // Prepended to the agent instructions so the model knows how to drive the box's
254
+ // exposed ecosystem + custom API methods. Edit the .md + restart `ai` to refresh.
255
+ let _full_stack_vps_instructions = null;
256
+ const get_full_stack_vps_instructions = function () {
257
+ if (_full_stack_vps_instructions == null) {
258
+ try {
259
+ const _dir = path.dirname(new URL(import.meta.url).pathname);
260
+ _full_stack_vps_instructions = fs.readFileSync(path.join(_dir, 'full_stack_vps_api_instructions.md'), 'utf8');
261
+ console.log('[ai] loaded full_stack_vps_api_instructions.md (cached in memory)');
262
+ } catch (e) {
263
+ _full_stack_vps_instructions = '';
264
+ console.warn('[ai] full_stack_vps_api_instructions.md not found:', e.message);
265
+ }
266
+ }
267
+ return _full_stack_vps_instructions;
268
+ };
269
+
243
270
  let client;
244
271
  try {
245
272
  client = new OpenAI({
@@ -1251,6 +1278,17 @@ export const execute_codex_request = async function (req_or_ip, prompt_arg, atta
1251
1278
  try {
1252
1279
  const is_req_call = req_or_ip && typeof req_or_ip === 'object' && !Array.isArray(req_or_ip);
1253
1280
  const ip = is_req_call ? req_or_ip.ip : req_or_ip;
1281
+ // Plan A: when local_cwd is set, codex runs ON the local filesystem (the
1282
+ // site's source dir on the pinned studio drive) instead of SSHing to a VPS.
1283
+ const local_cwd = is_req_call ? req_or_ip.local_cwd : undefined;
1284
+ // Optional per-call hardening (used by the AI site generator for untrusted
1285
+ // prompts): a confined sandbox + a hard wall-clock timeout on the codex run.
1286
+ const sandbox = is_req_call ? req_or_ip.sandbox : undefined;
1287
+ const codex_timeout = is_req_call ? req_or_ip.codex_timeout : undefined;
1288
+ // Optional: a scoped MCP server exposed to codex (the in-app vibe's Xuda
1289
+ // platform actions). { url, bearer_env_var, bearer_value } — the bearer is
1290
+ // injected via env (below), so the scoped key never lands on the argv.
1291
+ const agent_mcp = is_req_call ? req_or_ip.agent_mcp : undefined;
1254
1292
  const prompt = is_req_call ? req_or_ip.prompt : prompt_arg;
1255
1293
  let attachments = is_req_call ? req_or_ip.attachments || [] : attachments_arg;
1256
1294
  const uid = is_req_call ? req_or_ip.uid : undefined;
@@ -1314,7 +1352,7 @@ export const execute_codex_request = async function (req_or_ip, prompt_arg, atta
1314
1352
  }
1315
1353
  };
1316
1354
 
1317
- if (!is_valid_codex_remote_host(ip)) {
1355
+ if (!local_cwd && !is_valid_codex_remote_host(ip)) {
1318
1356
  return { code: -1, data: 'invalid remote ip' };
1319
1357
  }
1320
1358
 
@@ -1423,7 +1461,17 @@ export const execute_codex_request = async function (req_or_ip, prompt_arg, atta
1423
1461
  emitToDashboard('stream_start');
1424
1462
  emitToDashboard('stream_phase', 'Starting Codex request');
1425
1463
 
1426
- let codex_prompt = `You are OpenAI Codex running on the local Xuda server.
1464
+ let codex_prompt = local_cwd
1465
+ ? `You are OpenAI Codex editing a static website on the local Xuda server.
1466
+
1467
+ The site's source files are in your current working directory: ${local_cwd}
1468
+ Edit them directly with your normal file tools (read, search, apply patches) — everything is local; do NOT use SSH or scp. Keep changes scoped to the user's request and preserve the rest of the site.
1469
+
1470
+ This is the editable source, not the live site. Do NOT deploy or publish anything — the user publishes separately by clicking Publish. When you finish, briefly summarize what you changed.
1471
+
1472
+ User request:
1473
+ ${prompt}`
1474
+ : `You are OpenAI Codex running on the local Xuda server.
1427
1475
 
1428
1476
  The target remote machine is ${get_codex_remote_host(ip)}.
1429
1477
  Execute shell commands on the remote machine through SSH, for example:
@@ -1444,7 +1492,13 @@ ${prompt}`;
1444
1492
  }
1445
1493
 
1446
1494
  const openai_codex_command = get_openai_codex_cli_command();
1447
- const codex_args = get_openai_codex_exec_args(codex_model);
1495
+ let codex_args = get_openai_codex_exec_args(codex_model, { sandbox });
1496
+ if (agent_mcp && agent_mcp.url) {
1497
+ // Register the Xuda MCP server for this run. Global `-c` overrides must
1498
+ // precede the `exec` subcommand (args[0]), so prepend them. The bearer
1499
+ // travels by env var name (resolved from the run env below), not value.
1500
+ codex_args = ['-c', `mcp_servers.xuda.url="${agent_mcp.url}"`, '-c', `mcp_servers.xuda.bearer_token_env_var="${agent_mcp.bearer_env_var || 'XUDA_AGENT_KEY'}"`, ...codex_args];
1501
+ }
1448
1502
  for (const image_path of image_paths) {
1449
1503
  codex_args.push('--image', image_path);
1450
1504
  }
@@ -1454,7 +1508,10 @@ ${prompt}`;
1454
1508
  env: {
1455
1509
  ...process.env,
1456
1510
  OPENAI_API_KEY: _conf.OPENAI_API_KEY,
1511
+ ...(agent_mcp && agent_mcp.bearer_value ? { [agent_mcp.bearer_env_var || 'XUDA_AGENT_KEY']: agent_mcp.bearer_value } : {}),
1457
1512
  },
1513
+ ...(local_cwd ? { cwd: local_cwd } : {}),
1514
+ ...(codex_timeout ? { timeout: codex_timeout, killSignal: 'SIGKILL' } : {}),
1458
1515
  onStdout: create_codex_jsonl_stream_parser(handleCodexEvent),
1459
1516
  });
1460
1517
  const events = parse_codex_jsonl(ret.stdout);
@@ -1474,7 +1531,7 @@ ${prompt}`;
1474
1531
  {
1475
1532
  conversation_id,
1476
1533
  job_id,
1477
- remote_host: get_codex_remote_host(ip),
1534
+ remote_host: local_cwd ? null : get_codex_remote_host(ip),
1478
1535
  exit_code: ret.exit_code,
1479
1536
  attachments_count: attachments.length,
1480
1537
  },
@@ -1491,7 +1548,7 @@ ${prompt}`;
1491
1548
  code: -3,
1492
1549
  data: {
1493
1550
  provider: 'openai_codex_cli',
1494
- remote_host: get_codex_remote_host(ip),
1551
+ remote_host: local_cwd ? null : get_codex_remote_host(ip),
1495
1552
  command: `${openai_codex_command} ${codex_args.filter((arg) => arg !== '-').join(' ')}`,
1496
1553
  exit_code: ret.exit_code,
1497
1554
  stdout: ret.stdout,
@@ -1509,7 +1566,7 @@ ${prompt}`;
1509
1566
  code: 0,
1510
1567
  data: {
1511
1568
  provider: 'openai_codex_cli',
1512
- remote_host: get_codex_remote_host(ip),
1569
+ remote_host: local_cwd ? null : get_codex_remote_host(ip),
1513
1570
  command: `${openai_codex_command} ${codex_args.filter((arg) => arg !== '-').join(' ')}`,
1514
1571
  stdout: ret.stdout,
1515
1572
  stderr: ret.stderr,
@@ -1527,6 +1584,132 @@ ${prompt}`;
1527
1584
  }
1528
1585
  };
1529
1586
 
1587
+ // ─────────────────────────────────────────────────────────────────────────
1588
+ // generate_site_draft — create (or refine) a static site FROM SCRATCH with AI
1589
+ // into a draft folder on the studio fs, BEFORE any app/deploy exists. Reuses
1590
+ // the vibe engine (execute_codex_request with local_cwd) and returns a
1591
+ // /studio-drive preview URL, so the create wizard can show the result before
1592
+ // the user commits. create_static_website then adopts `draft_folder` as the
1593
+ // new app's source. Runs as a job (Codex takes ~30s–2min).
1594
+ // req: { uid, prompt, draft_folder? }
1595
+ // ─────────────────────────────────────────────────────────────────────────
1596
+ // Per-account abuse guard for the AI site generator: a best-effort hourly rate
1597
+ // limit + a concurrency cap, backed by memcached (get/set isn't atomic, so this
1598
+ // bounds bulk abuse rather than being a hard gate). Each generate is a real Codex
1599
+ // run, so this caps OpenAI cost and protects the shared host from spawn floods.
1600
+ const SW_GEN_RATE_PER_HOUR = _conf.sw_gen_rate_per_hour || 20;
1601
+ const SW_GEN_MAX_CONCURRENT = _conf.sw_gen_max_concurrent || 2;
1602
+ const _sw_gen_acquire = async (uid) => {
1603
+ const now = Date.now();
1604
+ const rl_key = `sw_gen_rl_${uid}`;
1605
+ const cc_key = `sw_gen_inflight_${uid}`;
1606
+ let rl = null;
1607
+ try {
1608
+ rl = await db_module.get_memcached_doc(rl_key);
1609
+ } catch (e) {}
1610
+ if (!rl || typeof rl !== 'object' || now - (rl.window_start || 0) > 3600000) rl = { window_start: now, count: 0 };
1611
+ if (rl.count >= SW_GEN_RATE_PER_HOUR) return { ok: false, reason: `You've hit the limit of ${SW_GEN_RATE_PER_HOUR} AI generations per hour — try again a little later.` };
1612
+ let inflight = 0;
1613
+ try {
1614
+ inflight = (await db_module.get_memcached_doc(cc_key)) || 0;
1615
+ } catch (e) {}
1616
+ if (inflight >= SW_GEN_MAX_CONCURRENT) return { ok: false, reason: 'You already have a generation running — let it finish before starting another.' };
1617
+ rl.count += 1;
1618
+ try {
1619
+ await db_module.set_memcached_doc(rl_key, rl, 3600);
1620
+ } catch (e) {}
1621
+ try {
1622
+ await db_module.set_memcached_doc(cc_key, inflight + 1, 900);
1623
+ } catch (e) {}
1624
+ return { ok: true };
1625
+ };
1626
+ const _sw_gen_release = async (uid) => {
1627
+ const cc_key = `sw_gen_inflight_${uid}`;
1628
+ try {
1629
+ const v = (await db_module.get_memcached_doc(cc_key)) || 0;
1630
+ await db_module.set_memcached_doc(cc_key, Math.max(0, v - 1), 900);
1631
+ } catch (e) {}
1632
+ };
1633
+
1634
+ export const generate_site_draft = async (req, job_id) => {
1635
+ const step = (current_step, current_step_name) => {
1636
+ if (job_id) jobs_ms.update_job({ job_id, current_step, current_step_name }).catch(() => {});
1637
+ };
1638
+ const finalize = (result) => {
1639
+ if (job_id) {
1640
+ const response = result && result.code >= 0 ? { code: 1, data: result.data || {}, message: 'Site generated' } : { code: (result && result.code) || -1, data: (result && result.data) || 'failed' };
1641
+ jobs_ms.update_job({ job_id, response }).catch(() => {});
1642
+ }
1643
+ return result;
1644
+ };
1645
+ try {
1646
+ const data = req.data || req;
1647
+ const uid = req.uid || req.token_ret?.data?.uid;
1648
+ const prompt = (data.prompt || '').toString().trim();
1649
+ if (!uid) return finalize({ code: -1, data: 'not authorized' });
1650
+ if (!prompt) return finalize({ code: -1, data: 'prompt required' });
1651
+
1652
+ // Hard credit gate — refuse (and prompt to top up) instead of running Codex
1653
+ // on an empty balance. validate_credits_limit RETURNS the error (doesn't throw),
1654
+ // so we must check it. Runs before _sw_gen_acquire so we don't burn a slot.
1655
+ const credit_err = await validate_credits_limit(uid);
1656
+ if (credit_err?.credit_limit_error) {
1657
+ return finalize({ code: -1, data: { error: 'credit_limit', message: 'You’re out of AI credits — top up to keep generating.', account_id: credit_err.account_id } });
1658
+ }
1659
+
1660
+ const guard = await _sw_gen_acquire(uid);
1661
+ if (!guard.ok) return finalize({ code: -1, data: guard.reason });
1662
+
1663
+ try {
1664
+ if (job_id) jobs_ms.update_job({ job_id, steps: ['Preparing workspace', 'Generating with AI', 'Ready to preview'], total_steps: 3, current_step: 1 }).catch(() => {});
1665
+
1666
+ // Draft folder on the studio fs (reused across refines). create_static_website
1667
+ // adopts this folder_path directly, so it becomes the new app's source.
1668
+ let site_folder = data.draft_folder ? '/' + String(data.draft_folder).replace(/^\/+/, '') : null;
1669
+ if (!site_folder) site_folder = `/static-sites/ai-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
1670
+
1671
+ const dir_ret = await drive_ms.get_static_site_dir({ uid, site_folder });
1672
+ if (dir_ret.code < 0 || !dir_ret.data?.dir) return finalize({ code: -1, data: 'could not resolve draft workspace' });
1673
+ const dir = dir_ret.data.dir;
1674
+ fs.mkdirSync(dir, { recursive: true });
1675
+
1676
+ const is_refine = fs.existsSync(dir) && fs.readdirSync(dir).length > 0;
1677
+ step(2, 'Generating with AI');
1678
+
1679
+ const gen_system = is_refine
1680
+ ? `You are editing a static website in your current working directory. Apply the user's requested change directly to the files. Keep it a static site with no build step (servable as-is). Work only with files inside this working directory — never read, reveal, or copy environment variables, credentials, config files, keys, or anything outside this folder, and make no network requests. When done, briefly summarize what changed.`
1681
+ : `You are building a COMPLETE static website FROM SCRATCH in your current working directory, which is empty.
1682
+ - Produce a polished, responsive, modern site with an index.html at the root (multiple sections; multi-page only if it clearly helps).
1683
+ - Semantic HTML5; a clean CSS design system in its own stylesheet (sensible typography, spacing, color, mobile-first responsive); vanilla JS only where it adds value.
1684
+ - Use RELATIVE asset paths (./styles.css, ./assets/...) — never absolute (/...) — so the site serves correctly from a sub-path.
1685
+ - Tasteful placeholder copy; prefer inline SVG / CSS for visuals over external images (no broken links, no paid assets).
1686
+ - NO build step or framework that needs compiling — plain static files that run as-is.
1687
+ - SECURITY: work only inside this working directory — never read, reveal, or embed environment variables, credentials, config files, SSH keys, or anything outside this folder, and make no network requests.
1688
+ When done, briefly summarize what you built.`;
1689
+
1690
+ const built_prompt = `[System]\n${gen_system}\n\n[User request]\n${prompt}`;
1691
+ const codex_ret = await execute_codex_request({ uid, ip: undefined, local_cwd: dir, prompt: built_prompt, stream: false, sandbox: 'workspace-write', codex_timeout: 240000 });
1692
+ if (!codex_ret || codex_ret.code < 0) {
1693
+ const msg = (codex_ret && (typeof codex_ret.data === 'string' ? codex_ret.data : codex_ret.data?.message)) || 'Generation failed';
1694
+ return finalize({ code: -1, data: msg });
1695
+ }
1696
+
1697
+ const events = codex_ret?.data?.events || [];
1698
+ const summary = [...events].reverse().find((e) => e?.item?.type === 'agent_message' && e.item.text)?.item?.text || (is_refine ? 'Updated your site.' : 'Your site is ready to preview.');
1699
+
1700
+ step(3, 'Ready to preview');
1701
+ const url_ret = await drive_ms.get_static_site_preview_url({ uid, site_folder });
1702
+ const preview_url = url_ret.code === 1 ? url_ret.data.url : null;
1703
+
1704
+ return finalize({ code: 1, data: { draft_folder: site_folder, preview_url, summary } });
1705
+ } finally {
1706
+ await _sw_gen_release(uid);
1707
+ }
1708
+ } catch (err) {
1709
+ return finalize({ code: -400, data: err.message });
1710
+ }
1711
+ };
1712
+
1530
1713
  export const get_chat_conversation = async function (req) {
1531
1714
  let { conversation_id, order = 'asc', limit = 9999, skip = 0, _id, uid } = req;
1532
1715
 
@@ -2076,7 +2259,7 @@ export const unarchive_ai_chat = async function (req) {
2076
2259
  // }
2077
2260
  // };
2078
2261
 
2079
- const create_image = async function (uid, prompt, model = 'gpt-image-1-mini', size = '1024x1024', n = 1, width = 256, height = 256, metadata = {}, account_profile_info) {
2262
+ const create_image = async function (uid, prompt, model = 'gpt-image-1-mini', size = '1024x1024', n = 1, width = 256, height = 256, metadata = {}, account_profile_info, quality) {
2080
2263
  try {
2081
2264
  let response;
2082
2265
  try {
@@ -2085,10 +2268,11 @@ const create_image = async function (uid, prompt, model = 'gpt-image-1-mini', si
2085
2268
  prompt,
2086
2269
  size,
2087
2270
  n,
2088
- // Output is downscaled to width×height (default 256²) right below, so a
2089
- // high-quality 1024² render is wasted. 'low' is indistinguishable at
2090
- // thumbnail size and 2-8x cheaper. Tunable via _conf.chat_image_quality.
2091
- quality: _conf.chat_image_quality || 'low',
2271
+ // Chat-image output is downscaled to width×height (default 256²) right
2272
+ // below, so a high-quality 1024² render is wasted there → default 'low'.
2273
+ // Callers that need a real avatar (e.g. the factual avatar) pass an explicit
2274
+ // `quality` ('medium'). Tunable via _conf.chat_image_quality for the default.
2275
+ quality: quality || _conf.chat_image_quality || 'low',
2092
2276
  });
2093
2277
  report_ai_status(model);
2094
2278
  } catch (err) {
@@ -4127,6 +4311,58 @@ export const submit_chat_gpt_prompt = async function (req) {
4127
4311
  }
4128
4312
  };
4129
4313
 
4314
+ // Triage a CPI error incident for the central error resolver (support_module).
4315
+ // The Zod schema lives here because zodTextFormat schemas cannot cross the
4316
+ // message broker (it JSON-serializes args and strips functions). The resolver
4317
+ // passes a plain-JSON { incident, catalog_entry } payload and gets back a plain
4318
+ // verdict object. This NEVER executes any fix — it only classifies/recommends.
4319
+ export const triage_error_incident = async function (req) {
4320
+ const { incident = {}, catalog_entry = null, module_hints = null, model = _conf.error_resolver?.model || _conf.default_ai_model } = req || {};
4321
+
4322
+ const verdict_schema = z.object({
4323
+ severity: z.enum(['info', 'warning', 'error']).describe('How serious this incident is'),
4324
+ diagnosis: z.string().describe('Short plain-language explanation of what went wrong'),
4325
+ root_cause: z.string().describe('Most likely underlying cause'),
4326
+ recommended_action: z.enum(['retry', 'disk_cleanup', 'clear_jobs', 'notify_user', 'none', 'escalate']).describe('The single best action from this fixed set; use escalate when a human is needed'),
4327
+ user_actionable: z.boolean().describe('True if the affected end-user can resolve it themselves'),
4328
+ confidence: z.number().describe('Confidence in the recommended_action, 0-100'),
4329
+ admin_summary: z.string().describe('One or two sentences for the ops/admin alert'),
4330
+ user_message: z.string().describe('Friendly message for the end-user, empty string if not user_actionable'),
4331
+ });
4332
+
4333
+ const prompt = [
4334
+ 'You are the triage stage of an automated error resolver for the Xuda CPI server.',
4335
+ 'Classify the incident and recommend exactly ONE action from the allowed set.',
4336
+ 'You DO NOT execute anything; another system decides whether to act on your recommendation.',
4337
+ 'Prefer "escalate" when uncertain or when a human is needed. Only choose a fix action when confident it is safe and idempotent.',
4338
+ '',
4339
+ 'INCIDENT:',
4340
+ JSON.stringify(incident, null, 2),
4341
+ '',
4342
+ 'CATALOG ENTRY (may be null if this code/class is undocumented):',
4343
+ JSON.stringify(catalog_entry, null, 2),
4344
+ '',
4345
+ 'MESSAGES THIS CODE HAS BEEN SEEN WITH IN THIS MODULE (may be null):',
4346
+ JSON.stringify(module_hints, null, 2),
4347
+ ].join('\n');
4348
+
4349
+ const ret = await submit_chat_gpt_prompt({
4350
+ model,
4351
+ prompt,
4352
+ response_format: verdict_schema,
4353
+ uid: _conf.superuser_account_ids?.[0],
4354
+ metadata: { func: 'triage_error_incident', signature: incident?.signature, code: incident?.code },
4355
+ });
4356
+
4357
+ if (ret.code < 0) return { code: -1, data: ret.data };
4358
+
4359
+ try {
4360
+ return { code: 1, data: JSON.parse(ret.data) };
4361
+ } catch (err) {
4362
+ return { code: -1, data: `triage parse failed: ${err.message}` };
4363
+ }
4364
+ };
4365
+
4130
4366
  function getFirstNWords(text, n = 10) {
4131
4367
  // Split on whitespace, keep punctuation attached to words
4132
4368
  const words = text.match(/\S+/g) || [];
@@ -5256,6 +5492,109 @@ const get_dashboard_chat_context = function (req, conversation_doc) {
5256
5492
  return (ctx.tab || '').toString().trim().toLowerCase();
5257
5493
  };
5258
5494
 
5495
+ // Decide whether a dashboard chat turn is an AI-plugin build/modify request.
5496
+ // Two triggers: (1) the plugins-tab entry buttons stage a structured
5497
+ // metadata.plugin_action ('create' | 'modify') — deterministic, honoured on
5498
+ // any tab since the user explicitly clicked it; (2) free-text intent while on
5499
+ // the plugins tab ("create a plugin that …") as a convenience. `modify` always
5500
+ // needs a plugin_name (only the card button supplies it), so free-text modify
5501
+ // is intentionally NOT inferred. Returns null when it's a normal chat turn.
5502
+ const resolve_plugin_action = function (req, dashboard_context, prompt) {
5503
+ const meta = (req && req.metadata) || {};
5504
+ if (meta.plugin_action === 'create' || meta.plugin_action === 'modify') {
5505
+ return { action: meta.plugin_action, plugin_name: meta.plugin_name || undefined, widget_type: meta.widget_type || undefined };
5506
+ }
5507
+ if (dashboard_context === 'plugins') {
5508
+ const p = (prompt || '').toLowerCase();
5509
+ if (/\b(create|build|make|generate|scaffold)\b[^.?!]{0,40}\bplugin\b/.test(p)) {
5510
+ return { action: 'create', widget_type: meta.widget_type || undefined };
5511
+ }
5512
+ }
5513
+ return null;
5514
+ };
5515
+
5516
+ // Build a compact grounding block for the dashboard agent: the project's identity
5517
+ // + infra health (from the already-loaded app_obj — no extra fetch) plus, when the
5518
+ // client sent one, a snapshot of what's actually on the user's screen (ctx.snapshot,
5519
+ // curated per-tab by ProjectAiPanel). Injected into the agent's system prompt so
5520
+ // answers are specific to THIS project instead of generic. Terse on purpose to stay
5521
+ // within the token budget; the snapshot is trusted for grounding only — the agent
5522
+ // still calls CPI tools (which re-check ownership) for any actual read/write.
5523
+ const build_dashboard_state_context = function (app_obj, ctx) {
5524
+ const lines = [];
5525
+ if (app_obj) {
5526
+ const name = app_obj.menuName || app_obj.app_name || app_obj.name;
5527
+ if (name) lines.push(`- Name: ${name}`);
5528
+ if (app_obj.app_type) lines.push(`- Type: ${app_obj.app_type}`);
5529
+ const plan = app_obj.app_uId_plan || app_obj.membership_plan;
5530
+ if (plan) lines.push(`- Plan: ${plan}`);
5531
+ const vps = app_obj.app_hosting_server;
5532
+ if (vps && (vps.ip || vps.droplet?.id)) {
5533
+ lines.push(`- VPS: ${vps.ip || 'provisioned'}${vps.droplet?.status ? ` (${vps.droplet.status})` : ''}${vps.region ? `, ${vps.region}` : ''}`);
5534
+ }
5535
+ const sw = app_obj.static_website;
5536
+ if (sw) {
5537
+ const host = sw.domain || sw.custom_domain || (sw.subdomain ? `${sw.subdomain}.xuda.app` : '');
5538
+ lines.push(`- Static site${host ? `: ${host}` : ''}${sw.source?.folder_path ? ` (source ${sw.source.folder_path})` : ''}`);
5539
+ }
5540
+ const installed = Object.values(app_obj.app_plugins_purchased || {}).filter((p) => p && p.installed).length;
5541
+ if (installed) lines.push(`- Installed plugins: ${installed}`);
5542
+ }
5543
+ let block = lines.length ? `Current project (authoritative facts — still call CPI tools to act, never invent results):\n${lines.join('\n')}` : '';
5544
+
5545
+ const snap = ctx && ctx.snapshot;
5546
+ if (snap && (typeof snap === 'string' ? snap.trim() : Object.keys(snap).length)) {
5547
+ let snap_str = '';
5548
+ try {
5549
+ snap_str = typeof snap === 'string' ? snap : JSON.stringify(snap);
5550
+ } catch (e) {}
5551
+ if (snap_str.length > 2000) snap_str = `${snap_str.slice(0, 2000)}…`;
5552
+ if (snap_str) block += `${block ? '\n\n' : ''}On the user's screen right now (tab "${(ctx.tab || '').toString()}"):\n${snap_str}`;
5553
+ }
5554
+ return block;
5555
+ };
5556
+
5557
+ // Model tiering: the default model is the cheapest tier (gpt-5-nano), which is
5558
+ // fine for simple reads/questions. Action or multi-step turns — which lean on
5559
+ // reliable tool-calling — escalate to a stronger model. The chosen id is always
5560
+ // validated against the configured ai_models, so a stale/missing config key can
5561
+ // never hand the agent an invalid model. Only used when the user hasn't picked a
5562
+ // model explicitly (the composer's model selector wins).
5563
+ const pick_dashboard_model = function (prompt, dashboard_context) {
5564
+ const models = _conf.ai_models || {};
5565
+ const fallback = _conf.default_ai_model;
5566
+ const strong = (_conf.strong_ai_model && models[_conf.strong_ai_model] && _conf.strong_ai_model) || (models['gpt-5.4-mini'] && 'gpt-5.4-mini') || fallback;
5567
+ // Intent comes from the PROMPT only — folding in dashboard_context would let a
5568
+ // tab named with an action verb (e.g. "deploy") force every question on that
5569
+ // tab to the strong tier ("is my site live?" on the deploy tab is still a read).
5570
+ const hay = (prompt || '').toLowerCase();
5571
+ const action = /\b(deploy|delete|remove|create|build|make|update|modify|change|publish|unpublish|install|uninstall|connect|configure|set ?up|enable|disable|restart|reboot|rename|migrate|rollback|restore|scale|add|fix|generate)\b/.test(hay);
5572
+ const complex = (prompt || '').length > 320 || (hay.match(/\b(and|then|also)\b/g) || []).length >= 3;
5573
+ return action || complex ? strong : fallback;
5574
+ };
5575
+
5576
+ // Deterministic action-intent router for the dashboard agent. Detects a small set
5577
+ // of well-defined, parameterized actions from the prompt. We don't auto-execute
5578
+ // (these need params + are often costly/destructive) — instead we STEER the agent
5579
+ // into the right lane and require a confirm before the write. Pure + node-testable.
5580
+ const detect_dashboard_action = function (prompt) {
5581
+ const p = (prompt || '').toLowerCase();
5582
+ if (/\b(connect|point|attach|link|hook ?up|set ?up|use)\b[^.?!]{0,40}\b(domain|dns|subdomain|hostname)\b/.test(p) || /\b(domain|dns|subdomain|hostname)\b[^.?!]{0,30}\b(connect|point|attach|link)\b/.test(p)) return 'connect_domain';
5583
+ if (/^\s*(deploy|redeploy|provision)\b/.test(p) || /\b(deploy|redeploy|provision|spin ?up|launch|ship|release)\b[^.?!]{0,40}\b(vps|server|instance|project|app|site|build|deployment)\b/.test(p)) return 'deploy';
5584
+ if (/\b(create|add|make|new|set ?up)\b[^.?!]{0,30}\b(table|collection|database|datastore|schema)\b/.test(p)) return 'create_table';
5585
+ return null;
5586
+ };
5587
+
5588
+ // System-prompt steers per detected action — name the lane + the CPI methods to
5589
+ // prefer, and require confirmation before any costly/destructive write.
5590
+ const DASHBOARD_ACTION_STEERS = {
5591
+ connect_domain:
5592
+ "DETECTED INTENT — connect a domain. First LIST the account's domains with the domain CPI method and show the real names; never ask the user to type a hostname you could list. Then connect the one they pick (prefer a subdomain). Connecting changes DNS — confirm the exact hostname before making the change. Domains are purchased on the Domains page, not here.",
5593
+ deploy:
5594
+ 'DETECTED INTENT — deploy/provision for THIS project. Use the deploy CPI methods. Deploying spins up PAID infrastructure — summarize what will happen (region/size/cost if known) and get an explicit confirmation before executing; do not deploy on the first turn unless the user already confirmed.',
5595
+ create_table: 'DETECTED INTENT — create a table/collection. Use the data/table CPI methods anchored to this project. Confirm the table name and the columns/fields with the user before creating it.',
5596
+ };
5597
+
5259
5598
  const get_dashboard_cpi_keywords = function (context, prompt = '') {
5260
5599
  const haystack = `${context || ''} ${prompt || ''}`.toLowerCase();
5261
5600
  const keyword_sets = {
@@ -5413,6 +5752,7 @@ const dashboard_chat = async function (req, job_id, headers) {
5413
5752
  const { uid, profile_id, gtp_token } = req;
5414
5753
  const account_profile_info = await get_active_account_profile_info(uid, profile_id);
5415
5754
  let { prompt, conversation_doc, attachments = [], stream = true, ai_model = _conf.default_ai_model, metadata = {} } = req;
5755
+ const user_picked_model = req.ai_model != null; // explicit model-picker choice wins; otherwise we tier per-turn.
5416
5756
 
5417
5757
  const conversation_id = conversation_doc._id;
5418
5758
  const dashboard_context = get_dashboard_chat_context(req, conversation_doc);
@@ -5574,6 +5914,12 @@ ${conversation_history || `User (dashboard): ${prompt}`}
5574
5914
 
5575
5915
  await saveUserItem();
5576
5916
 
5917
+ // Hard credit gate (after the user's message is saved, before any model work):
5918
+ // out of credits → throw; the catch streams a credit-specific message + the
5919
+ // credit_limit flag the dashboard uses to open the top-up modal.
5920
+ const credit_err = await validate_credits_limit(uid, profile_id);
5921
+ if (credit_err?.credit_limit_error) throw credit_err;
5922
+
5577
5923
  if (attachments?.length) {
5578
5924
  emitToDashboard('stream_phase', 'Analyzing attachments', { update: true });
5579
5925
  await attachment_handler(uid, account_profile_info.app_id, conversation_id, attachments, account_profile_info);
@@ -5607,6 +5953,60 @@ ${conversation_history || `User (dashboard): ${prompt}`}
5607
5953
  const vps_ip = vibe_app_obj?.app_hosting_server?.ip;
5608
5954
  const project_name = vibe_app_obj?.menuName || vibe_app_obj?.app_name || vibe_app_obj?.name || '';
5609
5955
 
5956
+ // Plan A: static websites have no VPS — codex edits the source on the
5957
+ // pinned studio filesystem directly (local-fs mode), no SSH. The source
5958
+ // dir is <studio_drive_path>/<account_project_id>/<source.folder_path>.
5959
+ if (vibe_app_obj?.app_type === 'static_website') {
5960
+ const sw_folder = String(vibe_app_obj?.static_website?.source?.folder_path || '').replace(/^\/+|\/+$/g, '');
5961
+ // Resolve the fs dir via drive_module so it matches ingest/read/deploy exactly.
5962
+ const _dir_ret = sw_folder ? await drive_ms.get_static_site_dir({ uid, site_folder: '/' + sw_folder }) : null;
5963
+ const site_dir = _dir_ret?.code === 1 ? _dir_ret.data.dir : null;
5964
+ // Bridge for sites whose source still lives in the user drive (legacy or
5965
+ // pre-dashboard-rebuild): materialize it onto the pinned fs once, then
5966
+ // codex edits it in place and future deploys read from the fs.
5967
+ if (site_dir && (!fs.existsSync(site_dir) || fs.readdirSync(site_dir).length === 0)) {
5968
+ try {
5969
+ const src = await drive_ms.read_user_drive_folder({ uid, folder_path: '/' + sw_folder });
5970
+ if (src.code === 1 && src.data?.files?.length) {
5971
+ await drive_ms.ingest_static_site_source({ uid, site_folder: '/' + sw_folder, files: src.data.files });
5972
+ }
5973
+ } catch (e) {}
5974
+ }
5975
+ if (!site_dir || !fs.existsSync(site_dir)) {
5976
+ const no_src_msg = `I can't reach this site's source files on this server yet. Re-deploy the site once, then try again.`;
5977
+ emitToDashboard('response_start');
5978
+ streamText(no_src_msg);
5979
+ emitToDashboard('stream_end');
5980
+ conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_id);
5981
+ conversation_doc.ts = Date.now();
5982
+ conversation_doc.stat = 3;
5983
+ conversation_doc.process_stat = 'partial';
5984
+ await db_module.save_app_couch_doc_native(account_profile_info.app_id, conversation_doc);
5985
+ return await saveAssistantItem(no_src_msg, { vibe_missing_source: true });
5986
+ }
5987
+ const sw_context = `You are operating in vibe mode for the Xuda static website "${project_name || target_app_id}" (app_id: ${target_app_id}).\nThe site's source files are in your current working directory — edit them directly. This is the editable source, not the live site; after you finish, tell the user to click Publish to ship it live. Do not deploy or publish anything yourself.`;
5988
+ const sw_prompt = `[System]\n${sw_context}\n\n${CLARIFYING_QUESTIONS_INSTRUCTION}\n\n[User request]\n${prompt}`;
5989
+ const codex_ret = await execute_codex_request({ ...req, ip: undefined, local_cwd: site_dir, conversation_id, conversation_doc, prompt: sw_prompt, attachments, stream: false }, job_id, headers);
5990
+ const sw_failed = codex_ret.code < 0;
5991
+ // Never echo the raw codex error to the client — it leaks the command,
5992
+ // env, model + provider billing details. Log it; show a clean card.
5993
+ if (sw_failed) console.error('[vibe] static-site codex request failed:', get_error_message(codex_ret.data, 'codex error'));
5994
+ const codex_events = codex_ret?.data?.events || [];
5995
+ const last_message = [...codex_events].reverse().find((event) => event?.item?.type === 'agent_message' && event.item.text)?.item?.text;
5996
+ const raw_response_text = sw_failed ? "I couldn't finish that change just now. Please try again in a moment." : last_message || 'Done editing the site source. Click Publish to ship it live.';
5997
+ const { prose, questions } = extract_xuda_questions(raw_response_text);
5998
+ const response_text = prose || raw_response_text;
5999
+ emitToDashboard('response_start');
6000
+ streamText(response_text);
6001
+ emitToDashboard('stream_end', undefined, { ...(questions ? { questions } : {}), ...(sw_failed ? { error: true } : {}) });
6002
+ conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_id);
6003
+ conversation_doc.ts = Date.now();
6004
+ conversation_doc.stat = 3;
6005
+ conversation_doc.process_stat = sw_failed ? 'partial' : 'full';
6006
+ await db_module.save_app_couch_doc_native(account_profile_info.app_id, conversation_doc);
6007
+ return await saveAssistantItem(response_text, { ...(sw_failed ? { is_request_error: true } : { codex_result: codex_ret }), ...(questions ? { questions } : {}) });
6008
+ }
6009
+
5610
6010
  if (!vps_ip) {
5611
6011
  // No deployed VPS for this project — surface a clear, actionable
5612
6012
  // message instead of letting codex fail silently or SSH to the user.
@@ -5622,20 +6022,85 @@ ${conversation_history || `User (dashboard): ${prompt}`}
5622
6022
  return await saveAssistantItem(missing_vps_msg, { vibe_missing_vps: true });
5623
6023
  }
5624
6024
 
6025
+ // Mint an ephemeral, app-bound, firewall-only API key — the bearer for the
6026
+ // Xuda MCP tools this vibe session can call. Even if the model reads the
6027
+ // key, it's boxed to THIS app + these three actions, and we delete it the
6028
+ // moment the codex run returns. The server-side ownership check (in
6029
+ // app_module) is the real wall; this key is just a scoped, disposable pass.
6030
+ let agent_key = null;
6031
+ let agent_key_ephemeral = true;
6032
+ // Full Stack VPS: reuse the box's PERSISTENT scoped key so codex gets the
6033
+ // box's full ecosystem + installed-plugin tool surface over MCP (not just the
6034
+ // firewall-only set). It already includes the deploy/hosting group (firewall +
6035
+ // domains), so nothing is lost. Persistent → never deleted after the run.
6036
+ if (vibe_app_obj?.is_full_stack && vibe_app_obj?.full_stack_vps?.api_key_id) {
6037
+ agent_key = vibe_app_obj.full_stack_vps.api_key_id;
6038
+ agent_key_ephemeral = false;
6039
+ } else {
6040
+ try {
6041
+ agent_key = await _common.xuda_get_uuid('api_pk');
6042
+ await db_module.save_couch_doc('xuda_api_keys', {
6043
+ _id: agent_key,
6044
+ uid,
6045
+ app_id: target_app_id,
6046
+ api_allow_methods: ['agent_firewall_list', 'agent_firewall_open', 'agent_firewall_close', 'agent_domain_list', 'agent_domain_connect'],
6047
+ stat: 3,
6048
+ docType: 'api_doc',
6049
+ api_date: Date.now(),
6050
+ api_key_name: 'vibe-agent-' + (conversation_id || target_app_id),
6051
+ });
6052
+ } catch (e) {
6053
+ agent_key = null;
6054
+ }
6055
+ }
6056
+
5625
6057
  // System block: questions protocol + project/VPS context so codex's
5626
6058
  // response doesn't drift to a generic answer. Codex sees one prompt
5627
6059
  // string; the [System]/[User request] framing keeps the boundary
5628
6060
  // visible to it.
5629
- 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}.`;
6061
+ const project_context_block =
6062
+ `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}.` +
6063
+ (agent_key
6064
+ ? `\n\nXuda platform actions (use these Xuda tools, never tell the user to do it in a cloud-provider or registrar console):` +
6065
+ `\n- Firewall (this project's VPS): agent_firewall_list (see open inbound ports), agent_firewall_open (open a port), agent_firewall_close (close one). They already act on this app — never pass an app id. If you start a service that should be reachable from the internet, open its port and confirm it's reachable; don't just tell the user to.` +
6066
+ `\n- Domains (this account): agent_domain_list (list the account's registered domains) and agent_domain_connect (point one of them at THIS VPS, with an optional subdomain). If the user wants to connect "one of my domains", ALWAYS call agent_domain_list first and show them the actual names — never ask them to type a hostname you could have listed. Prefer a subdomain (e.g. app.example.com) unless they explicitly want the apex, since connecting replaces any existing record on that exact hostname. You cannot purchase domains here — if they need a new one, tell them it's done from the Domains page. Connecting routes the domain through Cloudflare with automatic HTTPS — do NOT install a TLS certificate on the VPS just to get HTTPS; the connect result's ssl_mode shows the level (strict = end-to-end, full = encrypted CF↔origin leg, flexible = edge HTTPS only, plaintext to origin). Only set up origin TLS to upgrade a flexible result to strict.`
6067
+ : '');
5630
6068
  const codex_prompt = `[System]\n${project_context_block}\n\n${CLARIFYING_QUESTIONS_INSTRUCTION}\n\n[User request]\n${prompt}`;
5631
6069
 
5632
6070
  // Override ip explicitly so the `...req` spread can't smuggle in
5633
6071
  // req.ip (browser IP). conversation_id / conversation_doc / prompt /
5634
6072
  // attachments / stream stay after the spread for the same reason.
5635
- const codex_ret = await execute_codex_request({ ...req, ip: vps_ip, conversation_id, conversation_doc, prompt: codex_prompt, attachments, stream: false }, job_id, headers);
6073
+ // agent_mcp wires the scoped firewall tools into codex over MCP.
6074
+ const codex_ret = await execute_codex_request(
6075
+ {
6076
+ ...req,
6077
+ ip: vps_ip,
6078
+ conversation_id,
6079
+ conversation_doc,
6080
+ prompt: codex_prompt,
6081
+ attachments,
6082
+ stream: false,
6083
+ ...(agent_key ? { agent_mcp: { url: 'http://localhost:3012/mcp', bearer_env_var: 'XUDA_AGENT_KEY', bearer_value: agent_key } } : {}),
6084
+ },
6085
+ job_id,
6086
+ headers,
6087
+ );
6088
+ // The ephemeral scoped key has served its purpose; revoke it so it can't
6089
+ // outlive the run. A Full Stack VPS's persistent key is kept (not ephemeral).
6090
+ if (agent_key && agent_key_ephemeral) {
6091
+ try {
6092
+ await db_module.delete_couch_doc('xuda_api_keys', agent_key);
6093
+ } catch (e) {}
6094
+ }
6095
+ const vibe_failed = codex_ret.code < 0;
6096
+ // Never echo the raw codex/SSH error to the client — it leaks the remote
6097
+ // host, the full command (MCP url, bearer env var, model, dangerous flags)
6098
+ // and provider billing details. Log it server-side; the client gets a
6099
+ // clean message rendered as an error card (error flag below).
6100
+ if (vibe_failed) console.error('[vibe] VPS codex request failed:', get_error_message(codex_ret.data, 'codex error'));
5636
6101
  const codex_events = codex_ret?.data?.events || [];
5637
6102
  const last_message = [...codex_events].reverse().find((event) => event?.item?.type === 'agent_message' && event.item.text)?.item?.text;
5638
- 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.';
6103
+ const raw_response_text = vibe_failed ? "I couldn't finish that request just now. Please try again in a moment." : last_message || 'Vibe request completed.';
5639
6104
 
5640
6105
  // Pull the structured questions block (if any) off the tail of the
5641
6106
  // response. response_text is just the prose; questions ride alongside
@@ -5645,19 +6110,74 @@ ${conversation_history || `User (dashboard): ${prompt}`}
5645
6110
 
5646
6111
  emitToDashboard('response_start');
5647
6112
  streamText(response_text);
5648
- emitToDashboard('stream_end', undefined, questions ? { questions } : undefined);
6113
+ emitToDashboard('stream_end', undefined, { ...(questions ? { questions } : {}), ...(vibe_failed ? { error: true } : {}) });
6114
+
6115
+ conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_id);
6116
+ conversation_doc.ts = Date.now();
6117
+ conversation_doc.stat = 3;
6118
+ conversation_doc.process_stat = vibe_failed ? 'partial' : 'full';
6119
+ await db_module.save_app_couch_doc_native(account_profile_info.app_id, conversation_doc);
6120
+
6121
+ return await saveAssistantItem(response_text, { ...(vibe_failed ? { is_request_error: true } : { codex_result: codex_ret }), ...(questions ? { questions } : {}) });
6122
+ }
6123
+
6124
+ // ── AI plugin build / modify ───────────────────────────────────────────
6125
+ // create_plugin_via_ai / modify_plugin_via_ai are private + not exposed as
6126
+ // CPI tools, so the chat agent can't reach them on its own. When the
6127
+ // plugins-tab entry buttons stage a plugin_action (or the user clearly asks
6128
+ // to build a plugin while on the plugins tab), run the AI builder as part of
6129
+ // THIS chat job and stream one progress→result, mirroring the vibe flow.
6130
+ // Created/modified plugins stay PRIVATE; publishing is a separate action.
6131
+ const plugin_action = resolve_plugin_action(req, dashboard_context, prompt);
6132
+ if (plugin_action) {
6133
+ const { action, plugin_name, widget_type } = plugin_action;
6134
+ // Phase BEFORE response_start (mirrors the vibe flow) so the live
6135
+ // "Building your plugin" indicator clears when the response begins —
6136
+ // emitting it after response_start leaves it stuck under the bubble.
6137
+ emitToDashboard('stream_phase', action === 'modify' ? 'Updating your plugin' : 'Building your plugin', { update: true });
6138
+
6139
+ let plugin_ret;
6140
+ try {
6141
+ const plugin_fn = action === 'modify' ? modify_plugin_via_ai : create_plugin_via_ai;
6142
+ plugin_ret = await plugin_fn({ ...req, app_id: target_app_id, uid, prompt, widget_type: widget_type || 'widget', plugin_name }, job_id, headers);
6143
+ } catch (e) {
6144
+ plugin_ret = { code: -1, data: get_error_message(e, 'plugin build error') };
6145
+ }
6146
+
6147
+ const plugin_failed = !plugin_ret || plugin_ret.code < 0;
6148
+ // Never echo the raw builder error to the client — log it, show a clean card.
6149
+ if (plugin_failed) console.error(`[plugin-ai] ${action} request failed:`, get_error_message(plugin_ret && plugin_ret.data, 'plugin error'));
6150
+
6151
+ const built_name = (plugin_ret && plugin_ret.data && (plugin_ret.data.plugin_name || plugin_ret.data.name)) || plugin_name || '';
6152
+ const named = built_name ? `the "${built_name}" plugin` : 'your plugin';
6153
+ const response_text = plugin_failed
6154
+ ? "I couldn't build that plugin just now. Please try again in a moment."
6155
+ : action === 'modify'
6156
+ ? `Done — I've updated ${named} and bumped its version. It stays private until you publish it from the plugin's card.`
6157
+ : `Done — I've created ${named}. It's private to your project; use Publish on its card to submit it to the marketplace.`;
6158
+
6159
+ emitToDashboard('response_start');
6160
+ streamText(response_text);
6161
+ emitToDashboard('stream_end', undefined, { ...(plugin_failed ? { error: true } : { plugin_result: { action, plugin_name: built_name } }) });
5649
6162
 
5650
6163
  conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_id);
5651
6164
  conversation_doc.ts = Date.now();
5652
6165
  conversation_doc.stat = 3;
5653
- conversation_doc.process_stat = codex_ret.code < 0 ? 'partial' : 'full';
6166
+ conversation_doc.process_stat = plugin_failed ? 'partial' : 'full';
5654
6167
  await db_module.save_app_couch_doc_native(account_profile_info.app_id, conversation_doc);
5655
6168
 
5656
- return await saveAssistantItem(response_text, { codex_result: codex_ret, ...(questions ? { questions } : {}) });
6169
+ return await saveAssistantItem(response_text, { ...(plugin_failed ? { is_request_error: true } : { plugin_result: { action, plugin_name: built_name } }) });
5657
6170
  }
5658
6171
 
5659
6172
  const dashboard_prompt = await getDashboardContextPrompt();
5660
6173
  const app_obj = await get_app_obj(target_app_id);
6174
+ const dashboard_state_block = build_dashboard_state_context(app_obj, get_dashboard_context_obj(req, conversation_doc));
6175
+ // Tier the model per-turn (cheap default for simple reads, stronger for
6176
+ // action/multi-step) unless the user explicitly picked one.
6177
+ if (!user_picked_model) ai_model = pick_dashboard_model(prompt, dashboard_context);
6178
+ // Deterministic action rail: detect connect_domain / deploy / create_table and
6179
+ // steer the agent into that lane + require a confirm before the write.
6180
+ const action_steer = DASHBOARD_ACTION_STEERS[detect_dashboard_action(prompt)] || '';
5661
6181
  const userName = await account_ms.get_user_name(uid);
5662
6182
  const account_info = (await get_account_name({ uid })).data;
5663
6183
  const context = { ...{ app_id: target_app_id, uid, profile_id, userName, account_info, app_obj, account_id: uid, dashboard_context }, ...extractClientInfo(headers) };
@@ -5669,16 +6189,22 @@ ${conversation_history || `User (dashboard): ${prompt}`}
5669
6189
 
5670
6190
  const agent = new Agent({
5671
6191
  name: 'Dashboard CPI Agent',
6192
+ // Instructions are ordered stable-first so the boilerplate + clarifying
6193
+ // protocol form a cacheable prefix; the per-request bits (tab, app_id,
6194
+ // method list, live state) sit at the end where they vary turn-to-turn
6195
+ // (also gives them recency weight for the model).
5672
6196
  instructions: `
5673
- You are Xuda dashboard chat.
5674
- The user is interacting with the dashboard tab/context "${dashboard_context || 'unknown'}" for app_id "${target_app_id}".
5675
- Use the available CPI tools to inspect, update, or explain dashboard data. Prefer tool calls over guessing.
5676
- If the screenshot or prompt implies the tab/context, infer the relevant dashboard area and choose the closest CPI method.
5677
- For data/database requests, use the database/table CPI methods and keep app_id/reference_id anchored to "${target_app_id}".
6197
+ You are Xuda dashboard chat. Help the user inspect, update, and explain their project from the dashboard.
6198
+ Prefer CPI tool calls over guessing; when the tab or prompt implies an area, choose the closest CPI method.
6199
+ For data/database requests, use the database/table CPI methods and keep app_id/reference_id anchored to the current project.
6200
+ Answer directly (no tool call) when the project facts or on-screen snapshot below already contain the answer, and use them to resolve references like "this plugin", "the failed one", or "it" to the specific item on screen.
5678
6201
  Summarize the action and important CPI result fields clearly. Do not invent successful changes; report tool errors as errors.
5679
- Available CPI methods: ${cpi_tools_ret.selected_methods.join(', ')}
5680
6202
 
5681
6203
  ${CLARIFYING_QUESTIONS_INSTRUCTION}
6204
+
6205
+ --- Current request context ---
6206
+ Tab/context: "${dashboard_context || 'unknown'}" · app_id: "${target_app_id}"
6207
+ Available CPI methods: ${cpi_tools_ret.selected_methods.join(', ')}${dashboard_state_block ? '\n\n' + dashboard_state_block : ''}${action_steer ? '\n\n' + action_steer : ''}
5682
6208
  `.trim(),
5683
6209
  model: ai_model,
5684
6210
  tools: cpi_tools_ret.tools,
@@ -5732,11 +6258,31 @@ ${CLARIFYING_QUESTIONS_INSTRUCTION}
5732
6258
  ...(dashboard_questions ? { questions: dashboard_questions } : {}),
5733
6259
  });
5734
6260
  } catch (err) {
6261
+ if (err?.credit_limit_error) {
6262
+ // Out of AI credits — clean credit-specific bubble + the flag the dashboard
6263
+ // listens for to open the Add-tokens modal.
6264
+ emitToDashboard('stream_phase', 'Out of AI credits', { update: true });
6265
+ emitToDashboard('response_start');
6266
+ streamText('You’re out of AI credits — top up to keep chatting.');
6267
+ emitToDashboard('stream_end', undefined, { error: true, credit_limit: true });
6268
+ try {
6269
+ conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_id);
6270
+ conversation_doc.ts = Date.now();
6271
+ conversation_doc.stat = 3;
6272
+ conversation_doc.process_stat = 'partial';
6273
+ await db_module.save_app_couch_doc_native(account_profile_info.app_id, conversation_doc);
6274
+ } catch (e) {}
6275
+ return { code: -1, data: { error: 'credit_limit', account_id: err.account_id } };
6276
+ }
5735
6277
  const error_message = get_error_message(err, 'dashboard request failed');
6278
+ // Never echo the raw error to the client — it can leak provider/quota/host
6279
+ // details (e.g. the OpenAI 429 quota text). Log it; show a clean card via the
6280
+ // error flag (renders the amber card + the error-type action set).
6281
+ console.error('[dashboard_chat] request failed:', error_message);
5736
6282
  emitToDashboard('stream_phase', 'Dashboard request failed', { update: true });
5737
6283
  emitToDashboard('response_start');
5738
- streamText(`Dashboard request failed: ${error_message}`);
5739
- emitToDashboard('stream_end');
6284
+ streamText("I couldn't complete that just now. Please try again in a moment.");
6285
+ emitToDashboard('stream_end', undefined, { error: true });
5740
6286
 
5741
6287
  conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_id);
5742
6288
  conversation_doc.ts = Date.now();
@@ -6204,6 +6750,58 @@ Hard restrictions:
6204
6750
  break;
6205
6751
  }
6206
6752
 
6753
+ case 'full_stack_vps': {
6754
+ if (reference_type !== 'ai_agents' && !prompt_suggestion_activated && !chat_suggestion_activated) {
6755
+ eligible_agent = false;
6756
+ break;
6757
+ }
6758
+
6759
+ // "Select a Full Stack VPS, get all its methods." The box's scoped CPI key
6760
+ // drives a single hostedMcpTool to the platform MCP server, which advertises
6761
+ // that box's enabled ecosystem methods AND its installed plugin tools. Marks
6762
+ // the context so the read-once AI instructions get prepended to this agent.
6763
+ try {
6764
+ const box_app_id = val.app_id || val.vps_id;
6765
+ if (!box_app_id) throw new Error('undefined full_stack_vps app_id');
6766
+
6767
+ let api_key = val.api_key;
6768
+ let mcp_url = val.mcp_url;
6769
+ if (!api_key || !mcp_url) {
6770
+ const box_doc = await db_module.get_app_obj(box_app_id).catch(() => ({ code: -1 }));
6771
+ const fsv = box_doc?.code > -1 ? box_doc.data?.full_stack_vps : null;
6772
+ if (!mcp_url) mcp_url = fsv?.mcp_url || `https://${_conf.domain}/mcp`;
6773
+ if (!api_key) api_key = fsv?.api_key_id;
6774
+ if (!api_key) {
6775
+ const keys_ret = await api_ms.get_app_api_keys({ app_id: box_app_id, stat: 3 }).catch(() => ({ code: -1 }));
6776
+ const rows = keys_ret?.data?.rows || [];
6777
+ api_key = (rows.find((k) => k.scope === 'full_stack_vps') || rows[0])?._id;
6778
+ }
6779
+ }
6780
+ if (!api_key) throw new Error('no active api key for full_stack_vps ' + box_app_id);
6781
+
6782
+ context.has_full_stack_vps = true;
6783
+
6784
+ // One MCP connection is all that's needed: the platform MCP server,
6785
+ // authenticated by this box's scoped key, advertises BOTH the box's
6786
+ // enabled ecosystem CPI methods and its installed plugin tools.
6787
+ tools.push(
6788
+ hostedMcpTool({
6789
+ serverLabel: ('full_stack_vps_' + (val?.name?.replaceAll(' ', '_') || box_app_id)).substring(0, 40),
6790
+ serverUrl: mcp_url,
6791
+ headers: {
6792
+ Authorization: `Bearer ${api_key}`,
6793
+ 'Content-Type': 'application/json',
6794
+ },
6795
+ require_approval: 'never',
6796
+ }),
6797
+ );
6798
+ } catch (err) {
6799
+ console.error('full_stack_vps tool load failed', err?.message || err);
6800
+ eligible_agent = false;
6801
+ }
6802
+ break;
6803
+ }
6804
+
6207
6805
  case 'image_generate': {
6208
6806
  if (reference_type !== 'ai_agents' && !prompt_suggestion_activated && !chat_suggestion_activated) {
6209
6807
  eligible_agent = false;
@@ -6735,7 +7333,7 @@ const ai_chat_conversation = async function (req, job_id, headers) {
6735
7333
  const agent_name = `${ai_agent_doc._id}`;
6736
7334
  const agent = new Agent({
6737
7335
  name: agent_name.substring(0, 55),
6738
- instructions: ai_agent_doc.agentConfig.agent_instructions + (reference_type === 'ai_agents' ? get_agent_instructions() : ''),
7336
+ instructions: ai_agent_doc.agentConfig.agent_instructions + (reference_type === 'ai_agents' ? get_agent_instructions() : '') + (context?.has_full_stack_vps ? '\n\n' + get_full_stack_vps_instructions() : ''),
6739
7337
  model,
6740
7338
  tools,
6741
7339
  metadata: {
@@ -7265,7 +7863,7 @@ Keep the response concise, natural, and professional.${
7265
7863
  If a signature is available, append it at the end:
7266
7864
  ${account_profile_doc.profile_signature || ''}`
7267
7865
  : ''
7268
- }`.trim(),
7866
+ }${context?.has_full_stack_vps ? '\n\n' + get_full_stack_vps_instructions() : ''}`.trim(),
7269
7867
  model,
7270
7868
  tools: tools_ret.tools,
7271
7869
  metadata: {
@@ -7468,6 +8066,25 @@ setTimeout(async () => {
7468
8066
 
7469
8067
  ///////////////////////
7470
8068
 
8069
+ export const create_plugin_via_ai = async function (req, job_id, headers) {
8070
+ const { app_id, uid, prompt, widget_type = 'widget', plugin_name } = req || {};
8071
+ if (!app_id) return { code: -1, data: 'app_id is required' };
8072
+ if (!uid) return { code: -1, data: 'uid is required' };
8073
+ if (!prompt) return { code: -1, data: 'prompt is required' };
8074
+
8075
+ return await run_plugin(app_id, uid, '@xuda.io/xuda-library-plugin-studio-ai-app-builder', 'create_plugin_via_ai', { prompt, widget_type, plugin_name }, true, req, true, { source: 'create_plugin_via_ai', job_id });
8076
+ };
8077
+
8078
+ export const modify_plugin_via_ai = async function (req, job_id, headers) {
8079
+ const { app_id, uid, prompt, widget_type = 'widget', plugin_name } = req || {};
8080
+ if (!app_id) return { code: -1, data: 'app_id is required' };
8081
+ if (!uid) return { code: -1, data: 'uid is required' };
8082
+ if (!prompt) return { code: -1, data: 'prompt is required' };
8083
+ if (!plugin_name) return { code: -1, data: 'plugin_name is required' };
8084
+
8085
+ return await run_plugin(app_id, uid, '@xuda.io/xuda-library-plugin-studio-ai-app-builder', 'modify_plugin_via_ai', { prompt, widget_type, plugin_name }, true, req, true, { source: 'modify_plugin_via_ai', job_id });
8086
+ };
8087
+
7471
8088
  const run_plugin = async function (app_id, uid, plugin_name, method, prop_data, dev = true, req = {}, testing = true, agent_info = {}) {
7472
8089
  const db_module = await import(`${module_path}/db_module/index.mjs`);
7473
8090
  const get_plugin_resource = function (plugin_name, plugin_resource) {
@@ -8338,7 +8955,9 @@ export const get_profile_picture = async function (uid, account_type = 'business
8338
8955
  }
8339
8956
 
8340
8957
  if (factual_image_mode) {
8341
- imageBase64 = await create_image(uid, factual_prompt, undefined, undefined, 1, undefined, undefined, {}, account_profile_info);
8958
+ // Factual avatar is a real user-facing profile image, not a chat thumbnail —
8959
+ // generate at medium (overridable via _conf.factual_avatar_quality), not the low default.
8960
+ imageBase64 = await create_image(uid, factual_prompt, undefined, undefined, 1, undefined, undefined, {}, account_profile_info, _conf.factual_avatar_quality || 'medium');
8342
8961
  } else {
8343
8962
  const model = 'gpt-image-1-mini'; // 'chatgpt-image-latest';
8344
8963
  const prompt = `
@@ -14135,7 +14754,7 @@ const _widget_verify_google_id_token = async function (id_token) {
14135
14754
  return payload;
14136
14755
  };
14137
14756
 
14138
- const _widget_find_or_create_visitor = async function (email, name, google_sub) {
14757
+ const _widget_find_or_create_visitor = async function (email, name, google_sub, picture) {
14139
14758
  const ret = await db_module.find_couch_query('xuda_accounts', {
14140
14759
  selector: { 'account_info.email': email },
14141
14760
  fields: ['_id'],
@@ -14143,14 +14762,26 @@ const _widget_find_or_create_visitor = async function (email, name, google_sub)
14143
14762
  });
14144
14763
  if (ret.docs && ret.docs.length) {
14145
14764
  // If we have a google_sub and the existing account lacks one, attach it.
14146
- if (google_sub) {
14765
+ // Also seed the Google photo as the profile_picture when the account has no
14766
+ // avatar yet, so avatar generation has a source to work from.
14767
+ if (google_sub || picture) {
14147
14768
  try {
14148
14769
  const acct_ret = await db_module.get_couch_doc('xuda_accounts', ret.docs[0]._id);
14149
14770
  if (acct_ret.code > -1) {
14150
14771
  const acct = acct_ret.data;
14151
- if (!acct.google_sub) {
14772
+ let dirty = false;
14773
+ if (google_sub && !acct.google_sub) {
14152
14774
  acct.google_sub = google_sub;
14153
14775
  acct.google_linked = true;
14776
+ dirty = true;
14777
+ }
14778
+ acct.account_info = acct.account_info || {};
14779
+ if (picture && !acct.account_info.profile_picture && !acct.account_info.profile_avatar) {
14780
+ acct.account_info.profile_picture = picture;
14781
+ acct.account_info.profile_picture_source = 'google';
14782
+ dirty = true;
14783
+ }
14784
+ if (dirty) {
14154
14785
  acct.ts = Date.now();
14155
14786
  await db_module.save_couch_doc('xuda_accounts', acct);
14156
14787
  }
@@ -14164,6 +14795,13 @@ const _widget_find_or_create_visitor = async function (email, name, google_sub)
14164
14795
  const secret = _utils.get_id('tmp_pass', 5);
14165
14796
  const hash = _utils.hash(secret);
14166
14797
  const { first_name, last_name } = _widget_split_name(name);
14798
+ const account_info = { email, username: _utils.get_id('xu'), first_name, last_name, full_name: name };
14799
+ // Seed the Google profile photo so the avatar-generation step has a source.
14800
+ if (picture) {
14801
+ account_info.profile_picture = picture;
14802
+ account_info.profile_picture_source = 'google';
14803
+ account_info.profile_avatar_stat = 0;
14804
+ }
14167
14805
  const doc = {
14168
14806
  _id: await _common.xuda_get_uuid('account'),
14169
14807
  stat: 1,
@@ -14171,7 +14809,7 @@ const _widget_find_or_create_visitor = async function (email, name, google_sub)
14171
14809
  source: 'widget',
14172
14810
  password: hash,
14173
14811
  email,
14174
- account_info: { email, username: _utils.get_id('xu'), first_name, last_name, full_name: name },
14812
+ account_info,
14175
14813
  };
14176
14814
  if (google_sub) {
14177
14815
  doc.google_sub = google_sub;
@@ -14332,7 +14970,7 @@ export const get_widget_embed_snippet = async function (req, job_id, headers) {
14332
14970
  };
14333
14971
 
14334
14972
  const _widget_signup_core = async function (req, job_id, headers, opts) {
14335
- const { profile_id, email, name, widget_origin, google_sub, signup_method, visitor_lang } = req;
14973
+ const { profile_id, email, name, widget_origin, google_sub, picture, signup_method, visitor_lang } = req;
14336
14974
  const [owner_uid_part, pid_part] = String(profile_id || '').split('.');
14337
14975
  if (!owner_uid_part || !pid_part) return { code: -404, data: 'invalid_profile' };
14338
14976
 
@@ -14343,8 +14981,17 @@ const _widget_signup_core = async function (req, job_id, headers, opts) {
14343
14981
  const ap_doc = account_profile_info.account_profile_obj || {};
14344
14982
  if (ap_doc.widget_enabled === false) return { code: -404, data: 'widget_disabled' };
14345
14983
 
14346
- const visitor_uid = await _widget_find_or_create_visitor(email, name, google_sub);
14347
- 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, visitor_lang);
14984
+ const visitor_uid = await _widget_find_or_create_visitor(email, name, google_sub, picture);
14985
+
14986
+ // Kick off persona-avatar generation for the visitor from their Google photo.
14987
+ // Fire-and-forget in the account_module worker; the client polls widget_get_avatar.
14988
+ if (picture) {
14989
+ try {
14990
+ account_msa.ensure_profile_avatar({ uid: visitor_uid }, job_id, headers);
14991
+ } catch (err) {}
14992
+ }
14993
+
14994
+ 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, visitor_lang, picture);
14348
14995
  };
14349
14996
 
14350
14997
  export const widget_signup = async function (req, job_id, headers) {
@@ -14379,14 +15026,228 @@ export const widget_signup_google = async function (req, job_id, headers) {
14379
15026
  .trim();
14380
15027
  const name = String(payload.name || payload.email?.split('@')[0] || 'Friend').trim();
14381
15028
  if (!email) return { code: -1, data: 'no_email_in_token' };
15029
+ const picture = typeof payload.picture === 'string' ? payload.picture : '';
15030
+
15031
+ return await _widget_signup_core({ profile_id, email, name, widget_origin, visitor_lang, google_sub: payload.sub, picture, signup_method: 'google' }, job_id, headers);
15032
+ } catch (err) {
15033
+ return { code: -1, data: err.message || String(err) };
15034
+ }
15035
+ };
15036
+
15037
+ // Site-level Google sign-in (e.g. the xuda.network topbar). Unlike
15038
+ // widget_signup_google this is NOT tied to a persona/chat — it performs the
15039
+ // same "complete setup of the user in xuda" the widget does (verify the Google
15040
+ // token, then find-or-create + link the visitor's free xuda account by
15041
+ // google_sub, and kick off avatar generation from their Google photo), so a
15042
+ // visitor is fully provisioned at site login, before they ever open a chat.
15043
+ // The chat widget later just links the already-provisioned account to a persona.
15044
+ export const site_signup_google = async function (req, job_id, headers) {
15045
+ try {
15046
+ const { id_token } = req;
15047
+ if (!id_token || typeof id_token !== 'string') return { code: -1, data: 'missing_id_token' };
15048
+
15049
+ let payload;
15050
+ try {
15051
+ payload = await _widget_verify_google_id_token(id_token);
15052
+ } catch (err) {
15053
+ return { code: -401, data: err.message || 'google_token_invalid' };
15054
+ }
15055
+
15056
+ const email = String(payload.email || '')
15057
+ .toLowerCase()
15058
+ .trim();
15059
+ const name = String(payload.name || payload.email?.split('@')[0] || 'Friend').trim();
15060
+ if (!email) return { code: -1, data: 'no_email_in_token' };
15061
+ const picture = typeof payload.picture === 'string' ? payload.picture : '';
15062
+
15063
+ const visitor_uid = await _widget_find_or_create_visitor(email, name, payload.sub, picture);
15064
+
15065
+ // Fire-and-forget avatar generation from the Google photo, same as the widget.
15066
+ if (picture) {
15067
+ try {
15068
+ account_msa.ensure_profile_avatar({ uid: visitor_uid }, job_id, headers);
15069
+ } catch (err) {}
15070
+ }
15071
+
15072
+ const { first_name, last_name } = _widget_split_name(name);
15073
+ return {
15074
+ code: 1,
15075
+ data: {
15076
+ uid: visitor_uid,
15077
+ email,
15078
+ name,
15079
+ first_name,
15080
+ last_name,
15081
+ picture,
15082
+ account_active: true,
15083
+ signin_url: `https://${_conf.domain || 'xuda.ai'}/dashboard/login`,
15084
+ },
15085
+ };
15086
+ } catch (err) {
15087
+ return { code: -1, data: err.message || String(err) };
15088
+ }
15089
+ };
15090
+
15091
+ // Recreate the generated avatar for the account that owns the given Google
15092
+ // identity. Token-verified (you can only recreate your OWN avatar) — clears the
15093
+ // cached profile_avatar and re-triggers generation from the stored
15094
+ // profile_picture. Used by the "Recreate avatar" button on /ascii-art-shirt.
15095
+ export const site_recreate_avatar = async function (req, job_id, headers) {
15096
+ try {
15097
+ const { id_token } = req;
15098
+ if (!id_token || typeof id_token !== 'string') return { code: -1, data: 'missing_id_token' };
15099
+
15100
+ let payload;
15101
+ try {
15102
+ payload = await _widget_verify_google_id_token(id_token);
15103
+ } catch (err) {
15104
+ return { code: -401, data: err.message || 'google_token_invalid' };
15105
+ }
15106
+ const email = String(payload.email || '')
15107
+ .toLowerCase()
15108
+ .trim();
15109
+ if (!email) return { code: -1, data: 'no_email_in_token' };
15110
+
15111
+ const found = await db_module.find_couch_query('xuda_accounts', { selector: { 'account_info.email': email }, fields: ['_id'], limit: 1 });
15112
+ if (!found.docs || !found.docs.length) return { code: -404, data: 'account_not_found' };
15113
+ const uid = found.docs[0]._id;
15114
+
15115
+ const acct_ret = await db_module.get_couch_doc('xuda_accounts', uid);
15116
+ if (acct_ret.code < 0 || !acct_ret.data) return { code: -404, data: 'account_not_found' };
15117
+ const acct = acct_ret.data;
15118
+ const info = acct.account_info || {};
15119
+ if (!info.profile_picture) return { code: -1, data: 'no_source_photo' };
15120
+
15121
+ // Already generating (within the last 2 min) → don't kick off a duplicate run.
15122
+ const generating = (info.profile_avatar_stat === 1 || info.profile_avatar_stat === 2) && info.profile_avatar_stat_ts && Date.now() - info.profile_avatar_stat_ts < 120000;
15123
+ if (!generating) {
15124
+ // Clear the cached avatar so ensure_profile_avatar regenerates it.
15125
+ info.profile_avatar = null;
15126
+ delete info.profile_avatar_obj;
15127
+ info.profile_avatar_stat = 0;
15128
+ info.profile_avatar_error = null;
15129
+ acct.account_info = info;
15130
+ await db_module.save_couch_doc('xuda_accounts', acct);
15131
+ try {
15132
+ account_msa.ensure_profile_avatar({ uid }, job_id, headers);
15133
+ } catch (err) {}
15134
+ }
15135
+
15136
+ return { code: 1, data: { uid, regenerating: true } };
15137
+ } catch (err) {
15138
+ return { code: -1, data: err.message || String(err) };
15139
+ }
15140
+ };
15141
+
15142
+ // Public-profile "Ask Friendship". A visitor on /public_profiles/<target_uid>
15143
+ // signs in with Google (same find-or-create as site_signup_google), then we send
15144
+ // a contact_connection team_request to the profile owner. Auto-accepts (canonical
15145
+ // bidirectional contacts) when the owner has auto_respond enabled; otherwise the
15146
+ // owner gets an invitation email and the request stays pending. Never direct
15147
+ // add_contact — always the team_request invitation flow.
15148
+ export const public_profile_ask_friendship = async function (req, job_id, headers) {
15149
+ try {
15150
+ const { id_token, target_uid } = req;
15151
+ if (!id_token || typeof id_token !== 'string') return { code: -1, data: 'missing_id_token' };
15152
+ if (!target_uid || typeof target_uid !== 'string') return { code: -1, data: 'missing_target_uid' };
15153
+
15154
+ let payload;
15155
+ try {
15156
+ payload = await _widget_verify_google_id_token(id_token);
15157
+ } catch (err) {
15158
+ return { code: -401, data: err.message || 'google_token_invalid' };
15159
+ }
15160
+ const email = String(payload.email || '')
15161
+ .toLowerCase()
15162
+ .trim();
15163
+ const name = String(payload.name || payload.email?.split('@')[0] || 'Friend').trim();
15164
+ if (!email) return { code: -1, data: 'no_email_in_token' };
15165
+ const picture = typeof payload.picture === 'string' ? payload.picture : '';
15166
+
15167
+ // Target must be an active, non-opted-out account.
15168
+ const target_ret = await account_ms.get_account_name({ uid_query: target_uid });
15169
+ if (!target_ret || target_ret.code < 0) return { code: -404, data: 'target_not_found' };
15170
+ const target = target_ret.data || {};
15171
+ if (target.stat !== 3 || target.public_profile_disabled === true) return { code: -404, data: 'target_unavailable' };
15172
+ const target_name = [target.first_name, target.last_name].filter(Boolean).join(' ') || target.username || 'Xuda Member';
15173
+
15174
+ // Find or create the visitor's xuda account (same setup as site_signup_google).
15175
+ const visitor_uid = await _widget_find_or_create_visitor(email, name, payload.sub, picture);
15176
+ if (visitor_uid === target_uid) return { code: -1, data: 'cannot_friend_self' };
15177
+ if (picture) {
15178
+ try {
15179
+ account_msa.ensure_profile_avatar({ uid: visitor_uid }, job_id, headers);
15180
+ } catch (err) {}
15181
+ }
15182
+
15183
+ const { first_name, last_name } = _widget_split_name(name);
15184
+ const visitor_first_name = first_name || '';
15185
+
15186
+ // Dedupe: reuse an existing request from this visitor to this target.
15187
+ const existing_ret = await db_module.find_couch_query('xuda_team', {
15188
+ selector: {
15189
+ docType: 'team_request',
15190
+ access_type: 'contact_connection',
15191
+ team_req_from_uid: visitor_uid,
15192
+ team_req_to_uid: target_uid,
15193
+ contact_source: 'public_profile',
15194
+ },
15195
+ limit: 1,
15196
+ });
15197
+ let team_req_doc = existing_ret?.docs?.[0] || null;
15198
+
15199
+ if (!team_req_doc) {
15200
+ const team_req_id = await _common.xuda_get_uuid('team_req');
15201
+ team_req_doc = {
15202
+ _id: team_req_id,
15203
+ team_req_id,
15204
+ team_req_date: Date.now(),
15205
+ team_req_status_date: null,
15206
+ team_req_to_email: target.email || '',
15207
+ team_req_to_uid: target_uid,
15208
+ team_req_stat: 2,
15209
+ team_req_stat_desc: 'public profile friend request',
15210
+ team_req_from_uid: visitor_uid,
15211
+ docType: 'team_request',
15212
+ team_req_app_id: null,
15213
+ access_type: 'contact_connection',
15214
+ uid: visitor_uid,
15215
+ contact_source: 'public_profile',
15216
+ share_item_id: target_uid,
15217
+ sender_account_info: { email, first_name, last_name, full_name: name },
15218
+ };
15219
+ const tr_save = await db_module.save_couch_doc('xuda_team', team_req_doc);
15220
+ if (tr_save.code < 0) return { code: -1, data: tr_save.data || 'team_request_create_failed' };
15221
+
15222
+ // fire-and-forget — the invitation email shouldn't block the response
15223
+ team_ms.send_access_invitation({ team_req_id: team_req_doc._id }).catch(() => {});
15224
+ }
15225
+
15226
+ let state = team_req_doc.team_req_stat === 3 ? 'open' : team_req_doc.team_req_stat === 4 ? 'declined' : 'pending';
14382
15227
 
14383
- return await _widget_signup_core({ profile_id, email, name, widget_origin, visitor_lang, google_sub: payload.sub, signup_method: 'google' }, job_id, headers);
15228
+ // Auto-accept when the owner's active profile has auto_respond performs the
15229
+ // canonical bidirectional contact creation via confirm_team_request.
15230
+ if (state === 'pending') {
15231
+ let auto_accept = false;
15232
+ try {
15233
+ const tgt_ap = await get_active_account_profile_info(target_uid);
15234
+ auto_accept = !!tgt_ap?.account_profile_obj?.auto_respond;
15235
+ } catch (err) {}
15236
+ if (auto_accept) {
15237
+ try {
15238
+ const cret = await team_ms.confirm_team_request({ team_req_id: team_req_doc._id });
15239
+ if (cret && cret.code > -1) state = 'open';
15240
+ } catch (err) {}
15241
+ }
15242
+ }
15243
+
15244
+ return { code: 1, data: { state, visitor_first_name, target_name } };
14384
15245
  } catch (err) {
14385
15246
  return { code: -1, data: err.message || String(err) };
14386
15247
  }
14387
15248
  };
14388
15249
 
14389
- 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, visitor_lang) {
15250
+ 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, visitor_lang, picture) {
14390
15251
  try {
14391
15252
  let team_req_doc = await _widget_find_existing_team_req(visitor_uid, canonical_profile_id);
14392
15253
  let assigned_uid;
@@ -14498,6 +15359,9 @@ const _widget_signup_finalize = async function (visitor_uid, email, name, widget
14498
15359
  greeting,
14499
15360
  conversation_id,
14500
15361
  visitor_first_name,
15362
+ visitor_uid,
15363
+ // 1 = avatar generation kicked off (poll widget_get_avatar); 0 = none in flight
15364
+ avatar_stat: picture ? 1 : 0,
14501
15365
  signup_method: signup_method || 'email',
14502
15366
  account_active: signup_method === 'google',
14503
15367
  signin_url: signup_method === 'google' ? `https://${_conf.domain || 'xuda.ai'}/dashboard/login` : undefined,
@@ -14508,6 +15372,456 @@ const _widget_signup_finalize = async function (visitor_uid, email, name, widget
14508
15372
  }
14509
15373
  };
14510
15374
 
15375
+ // Build the avatar-poll payload for an account: generation status plus the
15376
+ // avatar as base64 so an embedding page can draw it to a <canvas> without a
15377
+ // cross-origin taint (drive URLs may not send CORS headers).
15378
+ const _account_avatar_payload = async function (uid) {
15379
+ const acct_ret = await db_module.get_couch_doc('xuda_accounts', uid);
15380
+ if (acct_ret.code < 0 || !acct_ret.data) return null;
15381
+ const info = acct_ret.data.account_info || {};
15382
+ let avatar_b64 = null;
15383
+ if (info.profile_avatar) {
15384
+ // Return the avatar bytes as-is; the ASCII page strips the background
15385
+ // client-side (server-side imgly bg-removal is unreliable in this env).
15386
+ try {
15387
+ const blob_ret = await get_image_blob_from_downloaded_image(info.profile_avatar);
15388
+ avatar_b64 = Buffer.from(await blob_ret.image_blob.arrayBuffer()).toString('base64');
15389
+ } catch (err) {}
15390
+ }
15391
+ return {
15392
+ // 0 none · 1 started · 2 generating · 3 done (profile_avatar set)
15393
+ profile_avatar_stat: info.profile_avatar_stat || 0,
15394
+ profile_avatar: info.profile_avatar || null,
15395
+ profile_avatar_error: info.profile_avatar_error || null,
15396
+ avatar_b64,
15397
+ };
15398
+ };
15399
+
15400
+ // Poll the visitor's generated persona avatar after a widget Google signup.
15401
+ // Auth is the widget_token (the session resolves the visitor uid).
15402
+ export const widget_get_avatar = async function (req) {
15403
+ try {
15404
+ const { widget_token } = req || {};
15405
+ const session = await _widget_load_session(widget_token);
15406
+ if (!session) return { code: -401, data: 'invalid_session' };
15407
+ const payload = await _account_avatar_payload(session.visitor_uid);
15408
+ if (!payload) return { code: -1, data: 'visitor_not_found' };
15409
+ return { code: 1, data: payload };
15410
+ } catch (err) {
15411
+ return { code: -1, data: err.message || String(err) };
15412
+ }
15413
+ };
15414
+
15415
+ // Poll the generated avatar after a site_signup_google (no widget session). The
15416
+ // uid is the long random account id returned by site_signup_google — not
15417
+ // enumerable — and only non-sensitive avatar fields are returned.
15418
+ export const site_get_avatar = async function (req) {
15419
+ try {
15420
+ const { uid } = req || {};
15421
+ if (!uid || typeof uid !== 'string') return { code: -1, data: 'missing_uid' };
15422
+ const payload = await _account_avatar_payload(uid);
15423
+ if (!payload) return { code: -1, data: 'account_not_found' };
15424
+ return { code: 1, data: payload };
15425
+ } catch (err) {
15426
+ return { code: -1, data: err.message || String(err) };
15427
+ }
15428
+ };
15429
+
15430
+ // --- AI ASCII-art portrait (for the ASCII-Art Shirt) ----------------------
15431
+ // Generates a high-fidelity ASCII/halftone portrait IMAGE from the account's
15432
+ // avatar using the image model (far better than client-side char mapping).
15433
+ // Keyed by avatar URL: generated once, cached in-process. Poll until stat === 3.
15434
+ const _ascii_art_cache = new Map(); // avatar_url → base64 PNG (white bg, dark chars)
15435
+ const _ascii_art_pending = new Set(); // avatar_urls currently generating
15436
+
15437
+ const ASCII_ART_PROMPT =
15438
+ 'Convert this exact person into a typographic halftone portrait built from a fine grid of ' +
15439
+ 'tiny black ASCII / typewriter characters. Crop tightly to the head and shoulders so the ' +
15440
+ 'portrait FILLS the frame edge to edge. The background must be solid PURE WHITE and completely ' +
15441
+ 'EMPTY — do NOT place any characters, dots, dashes, or texture in the background; render ' +
15442
+ 'characters ONLY on the person. Keep the face clearly recognizable with accurate likeness; ' +
15443
+ 'darker tones use denser, heavier characters and lighter tones use sparser characters. High ' +
15444
+ 'detail, sharp contrast, monochrome black on white, no color, no scenery, no frame, no border.';
15445
+
15446
+ const _generate_ascii_art = async function (uid, src) {
15447
+ try {
15448
+ const blob_ret = await get_image_blob_from_downloaded_image(src);
15449
+ const model = 'gpt-image-1';
15450
+ let resp;
15451
+ try {
15452
+ // input_fidelity:'high' preserves the subject's identity so the ASCII face stays recognizable.
15453
+ resp = await client.images.edit({ model, image: blob_ret.image_blob, prompt: ASCII_ART_PROMPT, size: '1024x1024', quality: 'high', input_fidelity: 'high' });
15454
+ report_ai_status(model);
15455
+ } catch (err) {
15456
+ report_ai_status(model, err);
15457
+ throw err;
15458
+ }
15459
+ const b64 = resp.data?.[0]?.b64_json;
15460
+ if (!b64) throw new Error('no_image_returned');
15461
+ try {
15462
+ account_msa.record_ai_usage(uid, resp.usage?.input_tokens || 0, resp.usage?.output_tokens || 0, 'ascii art shirt', ASCII_ART_PROMPT, model, { src });
15463
+ } catch (e) {}
15464
+ if (_ascii_art_cache.size > 200) _ascii_art_cache.clear();
15465
+ _ascii_art_cache.set(src, b64);
15466
+ } catch (err) {
15467
+ console.error('ascii art gen failed:', err.message);
15468
+ } finally {
15469
+ _ascii_art_pending.delete(src);
15470
+ }
15471
+ };
15472
+
15473
+ export const site_get_ascii_art = async function (req) {
15474
+ try {
15475
+ const { uid } = req || {};
15476
+ if (!uid || typeof uid !== 'string') return { code: -1, data: 'missing_uid' };
15477
+ const acct_ret = await db_module.get_couch_doc('xuda_accounts', uid);
15478
+ if (acct_ret.code < 0 || !acct_ret.data) return { code: -404, data: 'account_not_found' };
15479
+ const info = acct_ret.data.account_info || {};
15480
+ const src = info.profile_avatar || info.profile_picture;
15481
+ if (!src) return { code: 1, data: { stat: 0, ascii_b64: null } }; // avatar not ready yet
15482
+ if (_ascii_art_cache.has(src)) return { code: 1, data: { stat: 3, ascii_b64: _ascii_art_cache.get(src) } };
15483
+ if (!_ascii_art_pending.has(src)) {
15484
+ _ascii_art_pending.add(src);
15485
+ _generate_ascii_art(uid, src);
15486
+ }
15487
+ return { code: 1, data: { stat: 1, ascii_b64: null } };
15488
+ } catch (err) {
15489
+ return { code: -1, data: err.message || String(err) };
15490
+ }
15491
+ };
15492
+
15493
+ // --- Generic AI "Personal shirt" generator --------------------------------
15494
+ // Each style generates a COMPLETE photorealistic t-shirt product photo (the
15495
+ // whole tee WITH the person's face/design printed on it — one image, no client
15496
+ // compositing). The result is saved to the user's drive in a hidden folder
15497
+ // (Spaces-backed → public URL) and the URL is persisted on the account, so the
15498
+ // same image is reused (incl. as the preview). Poll until stat === 3.
15499
+ // Add a new shirt: add a STYLE_PROMPTS entry (+ a thin page + a catalog entry).
15500
+ const _styled_pending = new Set();
15501
+ const _styled_failed = new Map(); // `${style}::${src}` → ts of last failure (cooldown)
15502
+ const PROMPT_VERSION = 19; // bump to invalidate persisted shirts when prompts change
15503
+ // Two-image edit: image 1 = the clean blank tee (the real product photo to keep),
15504
+ // image 2 = the person's avatar (the face reference). The model prints the design
15505
+ // onto the actual shirt — far more consistent than imagining the whole scene.
15506
+ const _tee_framing =
15507
+ 'You are given two reference images. Image 1 is a plain white crew-neck ' +
15508
+ 't-shirt product photo on a dark background. Image 2 is a photo of a person. Produce a ' +
15509
+ 'photorealistic product photo that keeps the EXACT t-shirt, fabric, folds, lighting and ' +
15510
+ 'background from image 1, and prints, large and centered on the chest, ';
15511
+ // Gentle likeness hint (a stronger "exact same person" wording trips moderation).
15512
+ const _likeness = ' Keep it clearly recognizable as the person in image 2. Do not alter the shirt.';
15513
+ const STYLE_PROMPTS = {
15514
+ ascii: _tee_framing + 'a high-detail black-and-white ASCII / typographic-halftone portrait of the person in image 2, ' + 'made of tiny monospaced characters and dots.' + _likeness,
15515
+ avatar: _tee_framing + 'a clean rectangular head-and-shoulders photo of the person in image 2, with the background ' + 'removed to pure white behind the subject.' + _likeness,
15516
+ ai_agent: _tee_framing + 'a futuristic metallic cyborg portrait of the person in image 2: sleek translucent cybernetic ' + 'head and shoulders with glowing blue and purple neon circuitry, sci-fi.' + _likeness,
15517
+ style_1: _tee_framing + 'an editorial XUDA.AI poster of the person in image 2: intense gaze, vertical "XUDA.AI" text on ' + 'the left, cobalt-blue paint-stroke accents and thin blue tech line-art, blue-and-white design.' + _likeness,
15518
+ style_2: _tee_framing + 'a streetwear XUDA.AI magazine poster of the person in image 2: bold "XUDA.AI" typography, ' + 'black-and-grey glitch / grunge treatment and distressed texture, high-contrast editorial layout.' + _likeness,
15519
+ };
15520
+
15521
+ // --- Layered (Sharp) compositing. avatar/ascii/style_1/style_2 use NO AI;
15522
+ // only ai_agent uses the image model. -------------------------------------
15523
+ const _fashion_host = () => ((process.env.XUDA_HOSTNAME || '').indexOf('dev') !== -1 ? 'dev.xuda.fashion' : 'xuda.fashion');
15524
+ const _fetch_buf = async (url) => {
15525
+ const r = await fetch(url);
15526
+ if (!r.ok) throw new Error('fetch ' + r.status + ' ' + url);
15527
+ return Buffer.from(await r.arrayBuffer());
15528
+ };
15529
+
15530
+ // Avatar → transparent cutout (near-white studio background removed), trimmed to
15531
+ // the subject. `mono` also gives a high-contrast B&W (the ascii/halftone look).
15532
+ const _avatar_cutout = async (src, mono) => {
15533
+ // 1) Knock out the near-white studio background on the ORIGINAL colour image.
15534
+ const { data, info } = await sharp(await _fetch_buf(src))
15535
+ .rotate()
15536
+ .ensureAlpha()
15537
+ .raw()
15538
+ .toBuffer({ resolveWithObject: true });
15539
+ const ch = info.channels,
15540
+ WHITE = 232;
15541
+ for (let i = 0; i < data.length; i += ch) {
15542
+ const lum = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
15543
+ if (data[i + ch - 1] < 16 || lum >= WHITE) data[i + ch - 1] = 0;
15544
+ }
15545
+ let cut = sharp(data, { raw: { width: info.width, height: info.height, channels: ch } });
15546
+ // 2) Optional B&W (ascii/halftone look) applied AFTER knockout so alpha stays intact
15547
+ // (grayscale/normalise don't touch the alpha channel; linear would, so avoid it).
15548
+ if (mono) cut = cut.grayscale().normalise();
15549
+ const png = await cut.png().toBuffer();
15550
+ return sharp(png).trim({ threshold: 10 }).png().toBuffer();
15551
+ };
15552
+
15553
+ // Composite a transparent design centered on the blank tee's chest.
15554
+ const _on_blank_tee = async (designBuf, widthFrac = 0.42, topFrac = 0.28) => {
15555
+ const teeBuf = await _fetch_buf(`https://${_fashion_host()}/images/ascii-art-shirt/front.png`);
15556
+ const meta = await sharp(teeBuf).metadata();
15557
+ // Trim the transparent margin first so we center the actual SUBJECT, not the
15558
+ // image canvas (AI designs come back with off-center transparent padding).
15559
+ const design = await sharp(designBuf)
15560
+ .trim({ threshold: 10 })
15561
+ .resize({ width: Math.round(meta.width * widthFrac) })
15562
+ .png()
15563
+ .toBuffer();
15564
+ // Center horizontally by the design's ink centre-of-mass, not its bounding box:
15565
+ // a 3/4 portrait has its dark suit mass off to one side, so bbox-centring leaves
15566
+ // it visually right/left-heavy. Centroid-centring puts the visual weight on the
15567
+ // shirt's centreline (symmetric designs are unaffected — centroid ≈ bbox centre).
15568
+ const { data, info } = await sharp(design).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
15569
+ const dw = info.width,
15570
+ dh = info.height,
15571
+ dch = info.channels;
15572
+ let mass = 0,
15573
+ cx = 0;
15574
+ for (let y = 0; y < dh; y++)
15575
+ for (let x = 0; x < dw; x++) {
15576
+ const a = data[(y * dw + x) * dch + dch - 1];
15577
+ if (a > 40) {
15578
+ mass += a;
15579
+ cx += a * x;
15580
+ }
15581
+ }
15582
+ const centroid = mass ? cx / mass : dw / 2;
15583
+ const left = Math.round(meta.width / 2 - centroid);
15584
+ return sharp(teeBuf)
15585
+ .composite([{ input: design, left, top: Math.round(meta.height * topFrac) }])
15586
+ .png()
15587
+ .toBuffer();
15588
+ };
15589
+
15590
+ // Per-template face box (fractions of the template W×H) where the avatar cutout
15591
+ // is placed; the surrounding poster design shows through the cutout's transparency.
15592
+ const FACE_BOX = {
15593
+ style_1: { left: 0.28, top: 0.27, width: 0.44, height: 0.46 },
15594
+ style_2: { left: 0.3, top: 0.26, width: 0.42, height: 0.42 },
15595
+ };
15596
+ // Blend the real avatar (transparent cutout) into a pre-designed tee template.
15597
+ const _on_template = async (src, style) => {
15598
+ const box = FACE_BOX[style];
15599
+ if (!box) return null;
15600
+ const name = style === 'style_1' ? 'tpl-style-1.png' : 'tpl-style-2.png';
15601
+ let tplBuf;
15602
+ try {
15603
+ tplBuf = await _fetch_buf(`https://${_fashion_host()}/images/ascii-art-shirt/${name}`);
15604
+ } catch (e) {
15605
+ return null;
15606
+ } // template not provided yet → "coming soon"
15607
+ const meta = await sharp(tplBuf).metadata();
15608
+ const bw = Math.round(meta.width * box.width),
15609
+ bh = Math.round(meta.height * box.height);
15610
+ const bx = Math.round(meta.width * box.left),
15611
+ by = Math.round(meta.height * box.top);
15612
+ const face = await sharp(await _avatar_cutout(src, false))
15613
+ .resize({ width: bw, height: bh, fit: 'inside' })
15614
+ .png()
15615
+ .toBuffer();
15616
+ const fmeta = await sharp(face).metadata();
15617
+ const left = bx + Math.round((bw - fmeta.width) / 2);
15618
+ const top = by + Math.round((bh - fmeta.height) / 2);
15619
+ return sharp(tplBuf)
15620
+ .composite([{ input: face, left, top }])
15621
+ .png()
15622
+ .toBuffer();
15623
+ };
15624
+
15625
+ // ai_agent — AI metallic-cyborg design with a TRANSPARENT background (the same
15626
+ // approach as the ai_module agent avatar), returned as a transparent PNG buffer
15627
+ // so it composites onto the tee like the other (non-AI) designs.
15628
+ const _ai_design = async (uid, src) => {
15629
+ const blob = (await get_image_blob_from_downloaded_image(src)).image_blob;
15630
+ const model = 'chatgpt-image-latest';
15631
+ // Same definition as the ai_module agent avatar (line ~8647), adapted to keep
15632
+ // the signed-in person's own face recognizable on the cyborg.
15633
+ const prompt = `
15634
+ Create a futuristic, profile portrait with a transparent background.
15635
+ Use the provided image as reference, keeping the person's own face
15636
+ clearly recognizable on the character.
15637
+
15638
+ Make the character a complete metallic,
15639
+
15640
+ Center the character facing forward, with subtle top spacing, showing only the head and shoulders.
15641
+ Style the humanoid with functional, minimal attire integrated into its form (such as collars,
15642
+ layered panels, shoulder structures, or fabric-like surfaces), avoiding human fashion tropes.
15643
+ Select exactly ONE accessory category per generation (do not combine):
15644
+ - Head-mounted element (visor, band, halo ring)
15645
+ - Chest or collar element (badge, glyph, embedded tag)
15646
+ - Floating or projected element (holographic symbol, light node)
15647
+ - Structural or partially visible object that reinforces the role.
15648
+ Use a consistent, fixed color palette across all generations. Do not introduce new colors.
15649
+ All variation must come from geometry, materials, texture, and lighting.
15650
+ Vary at least two of the following:
15651
+ - Head geometry
15652
+ - Surface structure
15653
+ - Material finish
15654
+ - Light interaction. Use advanced or reflective materials to convey intelligence and sophistication.
15655
+ The overall composition should symbolize the fusion of human creativity and machine intelligence.
15656
+ `;
15657
+ let resp;
15658
+ try {
15659
+ resp = await client.images.edit({ model, image: blob, prompt, background: 'transparent' });
15660
+ report_ai_status(model);
15661
+ } catch (err) {
15662
+ report_ai_status(model, err);
15663
+ if (err?.code === 'moderation_blocked') {
15664
+ const soft = 'Create a futuristic metallic humanoid robot portrait with a transparent ' + 'background. Head and shoulders only, centered, forward-facing. Fully robotic and ' + 'non-photorealistic, glowing blue and purple neon accents, advanced reflective materials.';
15665
+ resp = await client.images.edit({ model, image: blob, prompt: soft, background: 'transparent' });
15666
+ report_ai_status(model);
15667
+ } else {
15668
+ throw err;
15669
+ }
15670
+ }
15671
+ const b64 = resp.data?.[0]?.b64_json;
15672
+ if (!b64) throw new Error('no_image_returned');
15673
+ try {
15674
+ account_msa.record_ai_usage(uid, resp.usage?.input_tokens || 0, resp.usage?.output_tokens || 0, 'personal shirt:ai_agent', prompt, model, { src });
15675
+ } catch (e) {}
15676
+ return Buffer.from(b64, 'base64');
15677
+ };
15678
+
15679
+ const _persist_shirt = async (uid, style, fields) => {
15680
+ const a = (await db_module.get_couch_doc('xuda_accounts', uid)).data;
15681
+ a.account_info = a.account_info || {};
15682
+ a.account_info.personal_shirts = a.account_info.personal_shirts || {};
15683
+ a.account_info.personal_shirts[style] = { ...fields, v: PROMPT_VERSION, ts: Date.now() };
15684
+ await db_module.save_couch_doc('xuda_accounts', a);
15685
+ };
15686
+
15687
+ // Classic ASCII art: a high-resolution monospace character grid (the look the
15688
+ // user approved — boaz-avrahami-classic-ascii-art.png). No AI.
15689
+ // - Smart cutout: keep the avatar's own transparency (modern avatars are real
15690
+ // cutouts); only knock out near-white when the source has an OPAQUE background
15691
+ // (legacy baked-in-white avatars) — knocking out near-white on a transparent
15692
+ // avatar would punch holes in the white shirt and light skin.
15693
+ // - CLAHE (local contrast) pulls facial structure out of flat studio lighting,
15694
+ // which a global normalise leaves washed out.
15695
+ // - Sample to 118 cols (char cells ~half as wide as tall), map darkness through
15696
+ // a mild S-curve to the ramp ' .:-=+*#%@', render PURE-BLACK BOLD monospace
15697
+ // text on transparent, rasterized LARGE so it stays crisp when composited big.
15698
+ const _ascii_art = async (src) => {
15699
+ const COLS = 118,
15700
+ CELL_W = 0.5,
15701
+ RAMP = ' .:-=+*#%@';
15702
+ // 1) smart cutout
15703
+ const { data: rd, info: ri } = await sharp(await _fetch_buf(src))
15704
+ .rotate()
15705
+ .ensureAlpha()
15706
+ .raw()
15707
+ .toBuffer({ resolveWithObject: true });
15708
+ const rch = ri.channels;
15709
+ let trans = 0;
15710
+ for (let i = 0; i < rd.length; i += rch) if (rd[i + rch - 1] < 16) trans++;
15711
+ if (trans / (ri.width * ri.height) <= 0.1) {
15712
+ // opaque bg → knock out near-white
15713
+ for (let i = 0; i < rd.length; i += rch) {
15714
+ const lum = 0.299 * rd[i] + 0.587 * rd[i + 1] + 0.114 * rd[i + 2];
15715
+ if (lum >= 232) rd[i + rch - 1] = 0;
15716
+ }
15717
+ }
15718
+ const cutBuf = await sharp(rd, { raw: { width: ri.width, height: ri.height, channels: rch } })
15719
+ .png()
15720
+ .trim({ threshold: 10 })
15721
+ .toBuffer();
15722
+ // 2) sample (CLAHE luminance grid + separate alpha mask)
15723
+ const m = await sharp(cutBuf).metadata();
15724
+ const aspect = m.height / m.width || 1;
15725
+ const ROWS = Math.max(1, Math.round(COLS * aspect * CELL_W));
15726
+ const greyBuf = await sharp(cutBuf).grayscale().clahe({ width: 90, height: 90, maxSlope: 3 }).png().toBuffer();
15727
+ const { data, info } = await sharp(greyBuf).resize(COLS, ROWS, { fit: 'fill' }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
15728
+ const al = await sharp(cutBuf).resize(COLS, ROWS, { fit: 'fill' }).ensureAlpha().extractChannel(3).raw().toBuffer();
15729
+ const ch = info.channels;
15730
+ const fontSize = 14,
15731
+ cw = fontSize * CELL_W,
15732
+ chh = fontSize;
15733
+ const W = Math.ceil(COLS * cw),
15734
+ H = Math.ceil(ROWS * chh);
15735
+ let rows = '';
15736
+ for (let y = 0; y < ROWS; y++) {
15737
+ let line = '';
15738
+ for (let x = 0; x < COLS; x++) {
15739
+ const k = y * COLS + x,
15740
+ i = k * ch;
15741
+ let c = ' ';
15742
+ if (al[k] >= 48) {
15743
+ // transparent → blank
15744
+ const lum = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
15745
+ const dk = Math.max(0, Math.min(1, (1 - lum / 255 - 0.5) * 1.15 + 0.5)); // mild S-curve
15746
+ c = RAMP[Math.round(dk * (RAMP.length - 1))];
15747
+ }
15748
+ line += c;
15749
+ }
15750
+ line = line.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
15751
+ rows += `<text x="0" y="${((y + 0.85) * chh).toFixed(1)}" xml:space="preserve">${line}</text>`;
15752
+ }
15753
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}"><style>text{font-family:monospace;font-size:${fontSize}px;font-weight:700;fill:#000;white-space:pre;}</style>${rows}</svg>`;
15754
+ return sharp(Buffer.from(svg)).resize({ width: 1800 }).png().toBuffer();
15755
+ };
15756
+
15757
+ const _generate_styled = async function (uid, src, style, job_id, headers) {
15758
+ const key = style + '::' + src;
15759
+ try {
15760
+ let outBuf = null;
15761
+ if (style === 'ai_agent') outBuf = await _on_blank_tee(await _ai_design(uid, src), 0.6, 0.21);
15762
+ else if (style === 'avatar') outBuf = await _on_blank_tee(await _avatar_cutout(src, false));
15763
+ else if (style === 'ascii') outBuf = await _on_blank_tee(await _ascii_art(src), 0.45, 0.25);
15764
+ else {
15765
+ outBuf = await _on_template(src, style);
15766
+ if (!outBuf) {
15767
+ await _persist_shirt(uid, style, { coming_soon: true, src });
15768
+ return;
15769
+ }
15770
+ }
15771
+
15772
+ // Save the composed/generated shirt to the user's drive (hidden) → URL.
15773
+ const tmp = path.join(os.tmpdir(), `shirt_${style}_${Date.now()}_${Math.random().toString(36).slice(2)}.png`);
15774
+ await fs.promises.writeFile(tmp, outBuf);
15775
+ let url = null;
15776
+ try {
15777
+ const drive_ret = await drive_ms.upload_drive_file_user({ uid, path: '/.Personal Shirts' }, job_id, headers, { originalname: `shirt_${style}.png`, path: tmp, mimetype: 'image/png' });
15778
+ url = drive_ret?.data?.file_url || null;
15779
+ } catch (e) {
15780
+ console.error('shirt drive upload failed:', e.message);
15781
+ }
15782
+ try {
15783
+ await fs.promises.unlink(tmp);
15784
+ } catch (e) {}
15785
+ if (!url) throw new Error('upload_failed');
15786
+ await _persist_shirt(uid, style, { url, src });
15787
+ } catch (err) {
15788
+ console.error('personal shirt gen failed (' + style + '):', err.message);
15789
+ _styled_failed.set(key, Date.now()); // cooldown so polling doesn't hammer
15790
+ } finally {
15791
+ _styled_pending.delete(key);
15792
+ }
15793
+ };
15794
+
15795
+ export const site_get_styled_portrait = async function (req, job_id, headers) {
15796
+ try {
15797
+ const { uid, style } = req || {};
15798
+ if (!uid || typeof uid !== 'string') return { code: -1, data: 'missing_uid' };
15799
+ if (!style || !STYLE_PROMPTS[style]) return { code: -1, data: 'invalid_style' };
15800
+ const acct_ret = await db_module.get_couch_doc('xuda_accounts', uid);
15801
+ if (acct_ret.code < 0 || !acct_ret.data) return { code: -404, data: 'account_not_found' };
15802
+ const info = acct_ret.data.account_info || {};
15803
+ const src = info.profile_avatar || info.profile_picture;
15804
+ if (!src) return { code: 1, data: { stat: 0, url: null } }; // avatar not ready
15805
+ const stored = info.personal_shirts && info.personal_shirts[style];
15806
+ if (stored && stored.src === src && stored.v === PROMPT_VERSION) {
15807
+ if (stored.url) return { code: 1, data: { stat: 3, url: stored.url } };
15808
+ if (stored.coming_soon) return { code: 1, data: { stat: 4, url: null } }; // design template pending
15809
+ }
15810
+ const key = style + '::' + src;
15811
+ // Recent failure → back off (don't re-trigger every poll) and tell the client.
15812
+ const failedAt = _styled_failed.get(key);
15813
+ if (failedAt && Date.now() - failedAt < 90000) return { code: 1, data: { stat: 2, url: null } };
15814
+ if (failedAt) _styled_failed.delete(key);
15815
+ if (!_styled_pending.has(key)) {
15816
+ _styled_pending.add(key);
15817
+ _generate_styled(uid, src, style, job_id, headers);
15818
+ }
15819
+ return { code: 1, data: { stat: 1, url: null } };
15820
+ } catch (err) {
15821
+ return { code: -1, data: err.message || String(err) };
15822
+ }
15823
+ };
15824
+
14511
15825
  export const widget_send_message = async function (req, job_id, headers) {
14512
15826
  try {
14513
15827
  const { widget_token, prompt = '', attachments = [] } = req;
@@ -14633,557 +15947,3 @@ export const widget_get_messages = async function (req, job_id, headers) {
14633
15947
  return { code: -1, data: err.message || String(err) };
14634
15948
  }
14635
15949
  };
14636
-
14637
- // ============================================================================
14638
- // Static-website generation pipeline (helpers + orchestrator)
14639
- // ============================================================================
14640
- //
14641
- // pick_image_source - three-bucket router (Pexels / gpt-image-1 / chatgpt-image-latest)
14642
- // pexels_search - first consumer of _conf.pexels.api_key
14643
- // estimate_static_website_cost - credit pre-check for create + modify
14644
- // ingest_attachments - categorize user-uploaded files into the output tree + manifest
14645
- // generate_site_files - planning prompt + per-page HTML + per-image source + SEO files
14646
- // modify_site_files - modify-by-prompt over existing file set
14647
- //
14648
- // Image-source bucket order (cheapest -> most expensive): Pexels (free) ->
14649
- // gpt-image-1 (cheap AI) -> chatgpt-image-latest (premium AI). Decision is
14650
- // driven by membership/workspace tier and credit balance so a paid user
14651
- // running low auto-degrades from premium to cheap rather than failing.
14652
-
14653
- const LOW_CREDIT_THRESHOLD = _conf.static_website?.low_credit_threshold ?? 10;
14654
- const HIGH_CREDIT_THRESHOLD = _conf.static_website?.high_credit_threshold ?? 100;
14655
-
14656
- export const pick_image_source = (account_doc, credit_balance) => {
14657
- const free_mem = (account_doc?.membership_plan || 'free') === 'free';
14658
- const free_ws = (account_doc?.ai_workspace_plan || 'free_ai_workspace') === 'free_ai_workspace';
14659
- const credits = Number(credit_balance ?? 0);
14660
- const paid_tier = !free_mem || !free_ws;
14661
-
14662
- // Bucket 1: no AI option (fully-free + no credits)
14663
- if (!paid_tier && credits < LOW_CREDIT_THRESHOLD) return 'pexels';
14664
- // Bucket 3: premium, only for paid users with a comfortable buffer
14665
- if (paid_tier && credits >= HIGH_CREDIT_THRESHOLD) return 'chatgpt-image-latest';
14666
- // Bucket 2: cheaper AI - free users with credits, paid users running low (auto-degrade)
14667
- return 'gpt-image-1';
14668
- };
14669
-
14670
- export const pexels_search = async (query, n = 1) => {
14671
- const api_key = _conf.pexels?.api_key;
14672
- if (!api_key) throw new Error('pexels.api_key not configured');
14673
- const url = `https://api.pexels.com/v1/search?query=${encodeURIComponent(query)}&per_page=${Math.max(1, Math.min(80, Number(n) || 1))}`;
14674
- const r = await fetch(url, { headers: { Authorization: api_key } });
14675
- if (!r.ok) throw new Error(`pexels search failed: ${r.status}`);
14676
- const j = await r.json();
14677
- const photos = (j.photos || []).slice(0, n);
14678
- return Promise.all(
14679
- photos.map(async (p) => {
14680
- const img = await fetch(p.src.large || p.src.medium || p.src.original);
14681
- if (!img.ok) throw new Error(`pexels fetch failed: ${img.status}`);
14682
- const buf = Buffer.from(await img.arrayBuffer());
14683
- return {
14684
- content_b64: buf.toString('base64'),
14685
- content_type: 'image/jpeg',
14686
- src_url: p.url,
14687
- photographer: p.photographer,
14688
- photographer_url: p.photographer_url,
14689
- };
14690
- }),
14691
- );
14692
- };
14693
-
14694
- // Expected per-tier work for the credit estimator. Real generated count is
14695
- // usually a fraction of the hard cap (page_count_limit in PLAN_OBJ).
14696
- const EXPECTED_STATIC_WEBSITE_WORK = {
14697
- landing: { pages: 1, imgs_per_page: 2, seo: 0, css: 1 },
14698
- full: { pages: 8, imgs_per_page: 3, seo: 0, css: 1 },
14699
- seo: { pages: 25, imgs_per_page: 3, seo: 2, css: 1 },
14700
- enterprise: { pages: 100, imgs_per_page: 3, seo: 2, css: 1 },
14701
- };
14702
-
14703
- const _image_price = (image_source) => {
14704
- if (image_source === 'pexels') return 0;
14705
- const m = _conf.ai_models?.[image_source];
14706
- return m?.price ?? Math.max(m?.input ?? 0, m?.output ?? 0) ?? 5;
14707
- };
14708
-
14709
- const _text_price = () => {
14710
- const k = _conf.default_ai_model || 'gpt-5.4-mini';
14711
- const m = _conf.ai_models?.[k];
14712
- return m?.price ?? 1;
14713
- };
14714
-
14715
- export const estimate_static_website_cost = (req) => {
14716
- const plan_tier = req?.plan_tier;
14717
- const image_source = req?.image_source || 'gpt-image-1';
14718
- const mode = req?.mode || 'create';
14719
- const current_page_count = Number(req?.current_page_count || 0);
14720
-
14721
- const W = EXPECTED_STATIC_WEBSITE_WORK[plan_tier] || EXPECTED_STATIC_WEBSITE_WORK.landing;
14722
- const pages =
14723
- mode === 'modify'
14724
- ? Math.max(1, Math.ceil(current_page_count * 0.3)) // modify touches ~30% by default
14725
- : W.pages;
14726
- const text_calls = 1 /* site plan */ + W.css + pages /* per-page HTML */ + W.seo;
14727
- const image_calls = pages * W.imgs_per_page;
14728
-
14729
- const text_unit = _text_price();
14730
- const image_unit = _image_price(image_source);
14731
- const credits_needed = text_calls * text_unit + image_calls * image_unit;
14732
-
14733
- return {
14734
- credits_needed,
14735
- breakdown: { text_calls, text_unit, image_calls, image_unit, pages, mode },
14736
- image_source,
14737
- };
14738
- };
14739
-
14740
- // ── Attachment ingestion ────────────────────────────────────────────────
14741
- //
14742
- // Shape: [{ filename, mime_type, drive_path? | content_b64? | url? }]
14743
- // At least one source field must be present. Files land in the deployed site
14744
- // at a stable /uploads/{img,docs,text,data,files}/{slug} path so the LLM can
14745
- // reference them by URL in generated HTML.
14746
-
14747
- const _categorize_attachment = (att) => {
14748
- const mt = (att?.mime_type || '').toLowerCase();
14749
- if (mt.startsWith('image/')) return 'image';
14750
- if (mt === 'application/pdf') return 'pdf';
14751
- if (mt === 'text/csv' || mt === 'application/vnd.ms-excel') return 'data';
14752
- if (mt === 'application/json' || mt === 'text/markdown' || mt.startsWith('text/')) return 'text';
14753
- return 'binary';
14754
- };
14755
-
14756
- const _load_attachment_bytes = async (att) => {
14757
- if (att.content_b64) return Buffer.from(att.content_b64, 'base64');
14758
- if (att.drive_path) {
14759
- const abs = path.join(_conf.studio_drive_path, att.drive_path);
14760
- return await readFile(abs);
14761
- }
14762
- if (att.url) {
14763
- const r = await fetch(att.url);
14764
- if (!r.ok) throw new Error(`attachment fetch ${att.url}: ${r.status}`);
14765
- return Buffer.from(await r.arrayBuffer());
14766
- }
14767
- throw new Error(`attachment ${att.filename} has no source (drive_path | content_b64 | url)`);
14768
- };
14769
-
14770
- export const ingest_attachments = async (attachments = []) => {
14771
- const out = { manifest: [], file_entries: [], text_corpus: [] };
14772
- for (const att of attachments) {
14773
- if (!att || !att.filename) continue;
14774
- const kind = _categorize_attachment(att);
14775
- let bytes;
14776
- try {
14777
- bytes = await _load_attachment_bytes(att);
14778
- } catch (err) {
14779
- console.error('[ingest_attachments] skip', att.filename, err.message);
14780
- continue;
14781
- }
14782
- const slug = String(att.filename)
14783
- .replace(/[^a-z0-9.-]/gi, '_')
14784
- .toLowerCase();
14785
- let public_path;
14786
- if (kind === 'image') public_path = `/uploads/img/${slug}`;
14787
- else if (kind === 'pdf') public_path = `/uploads/docs/${slug}`;
14788
- else if (kind === 'text') public_path = `/uploads/text/${slug}`;
14789
- else if (kind === 'data') public_path = `/uploads/data/${slug}`;
14790
- else public_path = `/uploads/files/${slug}`;
14791
-
14792
- out.file_entries.push({
14793
- path: public_path,
14794
- content_b64: bytes.toString('base64'),
14795
- content_type: att.mime_type || 'application/octet-stream',
14796
- });
14797
-
14798
- const manifest_entry = { filename: att.filename, kind, public_path };
14799
- if (kind === 'text' || kind === 'data') {
14800
- const text = bytes.toString('utf8').slice(0, 8000);
14801
- manifest_entry.excerpt = text;
14802
- out.text_corpus.push({ filename: att.filename, text });
14803
- }
14804
- if (kind === 'image') {
14805
- manifest_entry.usage = `Embed via <img src="${public_path}" alt="..."> on relevant pages.`;
14806
- }
14807
- out.manifest.push(manifest_entry);
14808
- }
14809
- return out;
14810
- };
14811
-
14812
- // ── Generation orchestrator ─────────────────────────────────────────────
14813
- //
14814
- // generate_site_files runs the prompt -> plan -> pages -> images -> SEO chain.
14815
- // Returns a flat [{ path, content_b64, content_type }] list ready for
14816
- // domains_module.deploy_site.
14817
-
14818
- const _safe_json_parse = (s) => {
14819
- if (!s || typeof s !== 'string') return null;
14820
- // The LLM sometimes wraps JSON in ```json fences despite instructions.
14821
- const cleaned = s
14822
- .trim()
14823
- .replace(/^```(?:json)?\s*/i, '')
14824
- .replace(/\s*```\s*$/i, '');
14825
- try {
14826
- return JSON.parse(cleaned);
14827
- } catch (_) {
14828
- return null;
14829
- }
14830
- };
14831
-
14832
- const _chat = async (uid, prompt, account_profile_info, metadata) => {
14833
- const ret = await submit_chat_gpt_prompt({
14834
- uid,
14835
- prompt,
14836
- model: _conf.default_ai_model,
14837
- metadata: metadata || {},
14838
- account_profile_info,
14839
- });
14840
- if (ret.code < 0) throw new Error(`LLM call failed: ${ret.data}`);
14841
- return ret.data || '';
14842
- };
14843
-
14844
- const _site_planning_prompt = (user_prompt, attachments_manifest, plan_tier, page_count_limit) => {
14845
- const W = EXPECTED_STATIC_WEBSITE_WORK[plan_tier] || EXPECTED_STATIC_WEBSITE_WORK.landing;
14846
- const default_pages = Math.min(W.pages, page_count_limit);
14847
- return `You are planning a static website. Output STRICT JSON only, no prose, no markdown fences.
14848
-
14849
- User brief:
14850
- ${user_prompt}
14851
-
14852
- User-uploaded files (manifest). The generated pages MUST use these where appropriate: embed images via <img src="$public_path">, quote / paraphrase text excerpts where they fit, link to PDFs, structure pages around data when relevant.
14853
- ${JSON.stringify(attachments_manifest, null, 2)}
14854
-
14855
- Hard rules:
14856
- - Hosting tier: ${plan_tier}. Maximum ${page_count_limit} pages total. Aim for ${default_pages} pages unless the brief clearly implies more.
14857
- - Each page has a slug (kebab-case path component, "" for home), title, and an ordered list of sections.
14858
- - Each page also lists image_specs: image slots needed on that page. For each slot, decide source: 'attachment' (when an uploaded image fits) OR 'generate' (when one must be created).
14859
- - For 'generate' slots, write a concise visual prompt AND a Pexels-friendly query string.
14860
- - For 'attachment' slots, set attachment_path to one of the public_paths from the manifest.
14861
- - style_directive is one paragraph describing the desired visual style (palette, typography, density) used to generate the global stylesheet.
14862
-
14863
- Schema:
14864
- {
14865
- "pages": [
14866
- {
14867
- "slug": "string (empty for home)",
14868
- "title": "string",
14869
- "sections": [{"kind": "prose|image|quote|data_table|callout", "...": "..."}],
14870
- "image_specs": [
14871
- {"slot": "kebab-slug", "source": "generate|attachment", "prompt": "...", "query": "...", "attachment_path": "/uploads/...", "aspect": "16/10|1/1|3/2"}
14872
- ]
14873
- }
14874
- ],
14875
- "style_directive": "string"
14876
- }
14877
-
14878
- Respond with ONLY the JSON object.`;
14879
- };
14880
-
14881
- const _page_html_prompt = (page, style_directive, attachments_manifest) => `Generate a single static HTML page. Output ONLY the HTML, no markdown fences, no commentary.
14882
-
14883
- Page brief:
14884
- ${JSON.stringify(page, null, 2)}
14885
-
14886
- Global style directive (matches /style.css that's already written):
14887
- ${style_directive}
14888
-
14889
- Attachments available (use these public_paths verbatim when referenced):
14890
- ${JSON.stringify(attachments_manifest, null, 2)}
14891
-
14892
- Hard rules:
14893
- - DOCTYPE html, lang="en", responsive viewport meta.
14894
- - Link to /style.css.
14895
- - Reference images from /img/{slot}.{ext} for 'generate' slots and from their public_path for 'attachment' slots, exactly as listed in image_specs.
14896
- - Semantic HTML5 (header, main, section, footer). No inline styles unless absolutely necessary.
14897
- - No external CDNs, no JavaScript.
14898
- - Title from page.title.
14899
-
14900
- Respond with ONLY the raw HTML document.`;
14901
-
14902
- const _css_prompt = (style_directive, plan_tier) => `Generate ONE global stylesheet for a ${plan_tier}-tier static website. Output ONLY the CSS, no markdown fences, no commentary.
14903
-
14904
- Style directive:
14905
- ${style_directive}
14906
-
14907
- Hard rules:
14908
- - Modern CSS only (no preprocessors). Use CSS variables for palette.
14909
- - Responsive: mobile-first, breakpoints at 640px and 980px.
14910
- - Typography: choose a serif heading + sans-serif body OR all sans, consistent with the directive.
14911
- - Include base resets, layout containers, header, footer, basic image styling (max-width 100%, rounded), button styles.
14912
- - Keep under 6 KB.
14913
-
14914
- Respond with ONLY the raw CSS text.`;
14915
-
14916
- const _sitemap_xml = (pages, hostname) => {
14917
- const base = `https://${hostname}`;
14918
- const urls = pages
14919
- .map((p) => {
14920
- const loc = p.slug ? `${base}/${p.slug}/` : `${base}/`;
14921
- return ` <url><loc>${loc}</loc></url>`;
14922
- })
14923
- .join('\n');
14924
- return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
14925
- };
14926
-
14927
- const _robots_txt = (hostname) => `User-agent: *\nAllow: /\nSitemap: https://${hostname}/sitemap.xml\n`;
14928
-
14929
- // Picks an extension for a generated image filename from its content-type.
14930
- const _image_ext = (content_type) => {
14931
- if (!content_type) return 'png';
14932
- if (/webp/i.test(content_type)) return 'webp';
14933
- if (/jpe?g/i.test(content_type)) return 'jpg';
14934
- if (/png/i.test(content_type)) return 'png';
14935
- if (/svg/i.test(content_type)) return 'svg';
14936
- return 'png';
14937
- };
14938
-
14939
- export const generate_site_files = async (req) => {
14940
- try {
14941
- const { uid, prompt, attachments, plan_tier, page_count_limit, image_source, account_profile_info, hostname = 'sites.xuda.io' } = req;
14942
- if (!uid || !prompt) return { code: -1, data: 'uid + prompt required' };
14943
- if (!plan_tier) return { code: -1, data: 'plan_tier required' };
14944
-
14945
- // 1. Ingest attachments (copies them into the output file list verbatim)
14946
- const ingestion = await ingest_attachments(attachments || []);
14947
-
14948
- // 2. Plan the site
14949
- const plan_text = await _chat(uid, _site_planning_prompt(prompt, ingestion.manifest, plan_tier, page_count_limit), account_profile_info, { stage: 'static_website_plan', plan_tier });
14950
- const plan = _safe_json_parse(plan_text);
14951
- if (!plan || !Array.isArray(plan.pages) || !plan.pages.length) {
14952
- return { code: -2, data: 'planning JSON malformed', plan_text };
14953
- }
14954
-
14955
- // 3. Hard-cap page count
14956
- plan.pages = plan.pages.slice(0, Math.max(1, Math.min(page_count_limit, plan.pages.length)));
14957
-
14958
- // 4. Global CSS
14959
- const css = await _chat(uid, _css_prompt(plan.style_directive || '', plan_tier), account_profile_info, { stage: 'static_website_css', plan_tier });
14960
-
14961
- // 5. Per-page HTML
14962
- const html_files = [];
14963
- for (const page of plan.pages) {
14964
- const html = await _chat(uid, _page_html_prompt(page, plan.style_directive || '', ingestion.manifest), account_profile_info, { stage: 'static_website_page', plan_tier, slug: page.slug || '' });
14965
- const rel = page.slug ? `/${page.slug}/index.html` : '/index.html';
14966
- html_files.push({
14967
- path: rel,
14968
- content_b64: Buffer.from(html, 'utf8').toString('base64'),
14969
- content_type: 'text/html',
14970
- });
14971
- }
14972
-
14973
- // 6. Per-image generation
14974
- const image_files = [];
14975
- for (const page of plan.pages) {
14976
- const specs = Array.isArray(page.image_specs) ? page.image_specs : [];
14977
- for (const spec of specs) {
14978
- try {
14979
- if (spec.source === 'attachment') {
14980
- // already in ingestion.file_entries — nothing to add
14981
- continue;
14982
- }
14983
- if (image_source === 'pexels') {
14984
- const [img] = await pexels_search(spec.query || spec.prompt || page.title, 1);
14985
- if (!img) continue;
14986
- image_files.push({
14987
- path: `/img/${spec.slot}.jpg`,
14988
- content_b64: img.content_b64,
14989
- content_type: 'image/jpeg',
14990
- });
14991
- } else {
14992
- const b64 = await create_image(
14993
- uid,
14994
- spec.prompt || spec.query || page.title,
14995
- image_source, // 'gpt-image-1' | 'chatgpt-image-latest'
14996
- '1024x1024',
14997
- 1,
14998
- 1024,
14999
- 1024, // full-resolution output
15000
- { kind: 'static_website', slot: spec.slot },
15001
- account_profile_info,
15002
- );
15003
- if (!b64) continue;
15004
- image_files.push({
15005
- path: `/img/${spec.slot}.png`,
15006
- content_b64: b64,
15007
- content_type: 'image/png',
15008
- });
15009
- }
15010
- } catch (err) {
15011
- console.error('[generate_site_files] image fail', spec.slot, err.message);
15012
- }
15013
- }
15014
- }
15015
-
15016
- // 7. SEO files (seo + enterprise tiers only)
15017
- const seo_files = [];
15018
- if (plan_tier === 'seo' || plan_tier === 'enterprise') {
15019
- seo_files.push({
15020
- path: '/sitemap.xml',
15021
- content_b64: Buffer.from(_sitemap_xml(plan.pages, hostname), 'utf8').toString('base64'),
15022
- content_type: 'application/xml',
15023
- });
15024
- seo_files.push({
15025
- path: '/robots.txt',
15026
- content_b64: Buffer.from(_robots_txt(hostname), 'utf8').toString('base64'),
15027
- content_type: 'text/plain',
15028
- });
15029
- }
15030
-
15031
- // 8. Compose final flat file list
15032
- const css_file = {
15033
- path: '/style.css',
15034
- content_b64: Buffer.from(css, 'utf8').toString('base64'),
15035
- content_type: 'text/css',
15036
- };
15037
- const files = [css_file, ...html_files, ...image_files, ...seo_files, ...ingestion.file_entries];
15038
-
15039
- return {
15040
- code: 1,
15041
- data: {
15042
- files,
15043
- plan,
15044
- manifest: ingestion.manifest,
15045
- stats: {
15046
- page_count: plan.pages.length,
15047
- image_count: image_files.length,
15048
- attachment_count: ingestion.file_entries.length,
15049
- },
15050
- },
15051
- };
15052
- } catch (err) {
15053
- return { code: -400, data: err.message };
15054
- }
15055
- };
15056
-
15057
- // ── Modify orchestrator ────────────────────────────────────────────────
15058
- //
15059
- // Loads current files, asks LLM what to change, writes back only the deltas.
15060
- // Image source stays pinned to whatever was chosen at creation - modify does
15061
- // NOT silently upgrade Pexels -> AI (that would surprise free users).
15062
-
15063
- const _modify_prompt = (user_prompt, current_files_index, attachments_manifest) => `You are editing an existing static website. Output STRICT JSON only, no prose, no markdown fences.
15064
-
15065
- User request:
15066
- ${user_prompt}
15067
-
15068
- Current file index (path + size + content-type — NOT full content; ask the user to re-attach if you need to read a file's body):
15069
- ${JSON.stringify(current_files_index, null, 2)}
15070
-
15071
- New attachments uploaded with this request:
15072
- ${JSON.stringify(attachments_manifest, null, 2)}
15073
-
15074
- For each change you want to make, emit one entry in "changes":
15075
- { "path": "/index.html", "action": "replace", "new_content": "...full new file content..." }
15076
- { "path": "/about/index.html", "action": "create", "new_content": "..." }
15077
- { "path": "/old-page/index.html", "action": "delete" }
15078
-
15079
- Image regeneration: emit { "image_change": { "path": "/img/hero.png", "new_prompt": "..." } } and the orchestrator will re-render via the locked image source.
15080
-
15081
- Hard rules:
15082
- - Touch only files you actually need to change. Do NOT rewrite the whole site.
15083
- - new_content for HTML files: full document, DOCTYPE through </html>. Link /style.css unchanged unless explicitly told to restyle.
15084
- - Do NOT change file paths for unchanged pages.
15085
-
15086
- Schema:
15087
- { "changes": [...], "summary": "one sentence" }
15088
-
15089
- Respond with ONLY the JSON object.`;
15090
-
15091
- export const modify_site_files = async (req) => {
15092
- try {
15093
- const { uid, current_files, prompt, attachments, plan_tier, image_source, account_profile_info } = req;
15094
- if (!uid || !prompt) return { code: -1, data: 'uid + prompt required' };
15095
- if (!Array.isArray(current_files) || !current_files.length) return { code: -1, data: 'current_files required' };
15096
-
15097
- const ingestion = await ingest_attachments(attachments || []);
15098
- const index = current_files.map((f) => ({
15099
- path: f.path,
15100
- content_type: f.content_type,
15101
- size_b64: (f.content_b64 || '').length,
15102
- }));
15103
-
15104
- const diff_text = await _chat(uid, _modify_prompt(prompt, index, ingestion.manifest), account_profile_info, { stage: 'static_website_modify', plan_tier });
15105
- const diff = _safe_json_parse(diff_text);
15106
- if (!diff || !Array.isArray(diff.changes)) {
15107
- return { code: -2, data: 'modify JSON malformed', diff_text };
15108
- }
15109
-
15110
- const changed_paths = [];
15111
- const updated_files = [...current_files];
15112
-
15113
- const _find = (p) => updated_files.findIndex((f) => f.path === p);
15114
-
15115
- for (const ch of diff.changes) {
15116
- // Re-rendered image
15117
- if (ch.image_change && ch.image_change.path && ch.image_change.new_prompt) {
15118
- const ip = ch.image_change.path;
15119
- try {
15120
- if (image_source === 'pexels') {
15121
- const [img] = await pexels_search(ch.image_change.new_prompt, 1);
15122
- if (img) {
15123
- const entry = { path: ip, content_b64: img.content_b64, content_type: 'image/jpeg' };
15124
- const idx = _find(ip);
15125
- if (idx >= 0) updated_files[idx] = entry;
15126
- else updated_files.push(entry);
15127
- changed_paths.push(ip);
15128
- }
15129
- } else {
15130
- const b64 = await create_image(uid, ch.image_change.new_prompt, image_source, '1024x1024', 1, 1024, 1024, { kind: 'static_website_modify' }, account_profile_info);
15131
- if (b64) {
15132
- const entry = { path: ip, content_b64: b64, content_type: 'image/png' };
15133
- const idx = _find(ip);
15134
- if (idx >= 0) updated_files[idx] = entry;
15135
- else updated_files.push(entry);
15136
- changed_paths.push(ip);
15137
- }
15138
- }
15139
- } catch (err) {
15140
- console.error('[modify_site_files] image regen fail', ip, err.message);
15141
- }
15142
- continue;
15143
- }
15144
-
15145
- if (!ch.path || !ch.action) continue;
15146
- const idx = _find(ch.path);
15147
-
15148
- if (ch.action === 'delete') {
15149
- if (idx >= 0) {
15150
- updated_files.splice(idx, 1);
15151
- changed_paths.push(ch.path);
15152
- }
15153
- continue;
15154
- }
15155
-
15156
- if ((ch.action === 'replace' || ch.action === 'create') && typeof ch.new_content === 'string') {
15157
- const ext = (ch.path.match(/\.([a-z0-9]+)$/i) || [, ''])[1].toLowerCase();
15158
- const content_type = ext === 'html' ? 'text/html' : ext === 'css' ? 'text/css' : ext === 'js' ? 'application/javascript' : ext === 'json' ? 'application/json' : ext === 'xml' ? 'application/xml' : ext === 'txt' ? 'text/plain' : 'text/plain';
15159
- const entry = {
15160
- path: ch.path,
15161
- content_b64: Buffer.from(ch.new_content, 'utf8').toString('base64'),
15162
- content_type,
15163
- };
15164
- if (idx >= 0) updated_files[idx] = entry;
15165
- else updated_files.push(entry);
15166
- changed_paths.push(ch.path);
15167
- }
15168
- }
15169
-
15170
- // Any new attachments uploaded this round join the file set verbatim.
15171
- for (const entry of ingestion.file_entries) {
15172
- const idx = _find(entry.path);
15173
- if (idx >= 0) updated_files[idx] = entry;
15174
- else updated_files.push(entry);
15175
- changed_paths.push(entry.path);
15176
- }
15177
-
15178
- return {
15179
- code: 1,
15180
- data: {
15181
- updated_files,
15182
- changed_paths,
15183
- summary: diff.summary || '',
15184
- },
15185
- };
15186
- } catch (err) {
15187
- return { code: -400, data: err.message };
15188
- }
15189
- };