@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/full_stack_vps_api_instructions.md +47 -0
- package/index.mjs +1355 -595
- package/index_ms.mjs +42 -22
- package/index_msa.mjs +38 -22
- package/package.json +1 -1
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
2089
|
-
// high-quality 1024² render is wasted
|
|
2090
|
-
//
|
|
2091
|
-
quality
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 } :
|
|
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 =
|
|
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, {
|
|
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
|
-
|
|
5675
|
-
|
|
5676
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
-
};
|