@xuda.io/ai_module 1.1.5657 → 1.1.5658
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.mjs +1294 -41
- package/index_ms.mjs +32 -0
- package/index_msa.mjs +32 -0
- package/package.json +1 -1
- package/xudex_engines.mjs +323 -0
- package/xudex_mirror.mjs +200 -0
- package/xudex_preview.mjs +263 -0
- package/xudex_run.mjs +231 -0
- package/xudex_runtime.mjs +200 -0
- package/xudex_tracker.mjs +276 -0
- package/xudex_verify.mjs +272 -0
- package/xudex_vm.mjs +218 -0
package/index.mjs
CHANGED
|
@@ -98,6 +98,27 @@ const run_process = function (command, args, input, options = {}) {
|
|
|
98
98
|
});
|
|
99
99
|
};
|
|
100
100
|
|
|
101
|
+
// UI-228 (xudex). The runtime seam: every xudex layer asks this for a WORKSPACE and never learns
|
|
102
|
+
// whether it got a VM, an ephemeral runner or a local working copy. See xudex_runtime.mjs and
|
|
103
|
+
// docs/plans/xudex.md section 3.8. Instantiated here rather than inside that file because
|
|
104
|
+
// run_process above is the only spawn wrapper whose detached process-group kill actually stops a
|
|
105
|
+
// Codex run, and a second copy of that fix is the last thing this codebase needs.
|
|
106
|
+
const { create_runtime: _create_xudex_runtime } = await import('./xudex_runtime.mjs');
|
|
107
|
+
const { create_mirror: _create_xudex_mirror } = await import('./xudex_mirror.mjs');
|
|
108
|
+
// The run tracker watches a workspace through the same handle everything else uses, so it
|
|
109
|
+
// works identically on a VM, an ephemeral runner or a container. See xudex_tracker.mjs.
|
|
110
|
+
const { create_run_tracker: create_xudex_run_tracker } = await import('./xudex_tracker.mjs');
|
|
111
|
+
// The engine registry: codex and Claude Code behind one launch/parse contract, so nothing above
|
|
112
|
+
// this line branches on which CLI is running. See xudex_engines.mjs.
|
|
113
|
+
const xudex_engines = await import('./xudex_engines.mjs');
|
|
114
|
+
// The verify loop: what makes a xudex run different from a terminal that says "done" and
|
|
115
|
+
// leaves your build broken. See xudex_verify.mjs.
|
|
116
|
+
const xudex_verify = await import('./xudex_verify.mjs');
|
|
117
|
+
// The last gate of that loop: is the app still actually running. See xudex_preview.mjs.
|
|
118
|
+
const { create_preview: _create_xudex_preview } = await import('./xudex_preview.mjs');
|
|
119
|
+
const xudex_preview = _create_xudex_preview({});
|
|
120
|
+
const xudex_runtime = _create_xudex_runtime({ run_process });
|
|
121
|
+
|
|
101
122
|
// UI-220. Same spawn, opposite lifetime: start the process, hand back its pid and walk away, with
|
|
102
123
|
// stdout and stderr going straight to a file that never passes through this process. A code run
|
|
103
124
|
// has to survive a pm2 restart, and on dev pm2 watches cpi/ai_module so every deploy is one.
|
|
@@ -270,11 +291,23 @@ const get_cheapest_ai_model_code = function () {
|
|
|
270
291
|
return best?.code || _conf.default_ai_model || null;
|
|
271
292
|
};
|
|
272
293
|
|
|
294
|
+
// Default model for a Codex run nobody picked a model for (the picker's "Auto").
|
|
295
|
+
// Deliberately NOT the cheapest catalog entry. A Codex run is not a chat turn: it
|
|
296
|
+
// drives real tools over many turns, and the cheapest model cannot hold that shape.
|
|
297
|
+
// Measured on dev 2026-08-13, a from-scratch site build on gpt-5-nano emitted three
|
|
298
|
+
// shell calls the CLI refused to parse ("failed to parse function arguments:
|
|
299
|
+
// duplicate field `cmd`"), fell back to writing the page through a bash heredoc that
|
|
300
|
+
// died on "syntax error near unexpected token `('", and spent its entire wall-clock
|
|
301
|
+
// budget with nothing written to disk. A run that fails costs its full spend and
|
|
302
|
+
// ships nothing, so the cheap default was never actually the cheap option.
|
|
303
|
+
const CODEX_DEFAULT_MODEL_CODE = 'gpt-2';
|
|
273
304
|
const get_openai_codex_model = function () {
|
|
274
305
|
// Codex is user-selectable per request (req.codex_model); when nothing is picked
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
|
|
306
|
+
// we use the default above. Returns a catalog code (resolved to a real OpenAI id
|
|
307
|
+
// at exec time by get_openai_codex_exec_args). Falls back to the cheapest
|
|
308
|
+
// codex-eligible entry only if that code is missing from the catalog.
|
|
309
|
+
const fallback = _conf.openai_codex_default_model || CODEX_DEFAULT_MODEL_CODE;
|
|
310
|
+
return _conf.openai_codex_model || (_is_codex_model(fallback) ? fallback : get_cheapest_ai_model_code());
|
|
278
311
|
};
|
|
279
312
|
|
|
280
313
|
// Map a legacy ai_models key still stored on old profiles/agents (e.g. 'gpt-5.4') to
|
|
@@ -498,6 +531,19 @@ const bot_ms = await import(`${module_path}/bot_protection_module/index_ms.mjs`)
|
|
|
498
531
|
// Owns whether the AI answers, and with what, on every channel. This module used to decide
|
|
499
532
|
// it from flat fields on the profile doc; see auto_response() below.
|
|
500
533
|
const auto_response_ms = await import(`${module_path}/auto_response_module/index_ms.mjs`);
|
|
534
|
+
// UI-228: the `xudex_run` identity gate. Declared at level 2 in verify_policy and currently
|
|
535
|
+
// SHADOWED, so this reports and never denies until the platform enforcement floor moves.
|
|
536
|
+
const verify_ms = await import(`${module_path}/verify_module/index_ms.mjs`);
|
|
537
|
+
// UI-228: `xudex_touch`, which is what the idle clock that suspends a machine measures from.
|
|
538
|
+
// The ASYNC wrapper deliberately: nothing about a run should wait on a timestamp being written.
|
|
539
|
+
const deploy_msa = await import(`${module_path}/deploy_module/index_msa.mjs`);
|
|
540
|
+
// The SYNC twin: the VM substrate has to know which machine it got and what Proxmox answered,
|
|
541
|
+
// so those two calls need a reply rather than a fire-and-forget.
|
|
542
|
+
const deploy_ms = await import(`${module_path}/deploy_module/index_ms.mjs`);
|
|
543
|
+
// The entitlement rules themselves, imported as plain logic rather than called over the broker.
|
|
544
|
+
// They are a pure function of the account document, so a queue round trip would buy nothing and
|
|
545
|
+
// cost a hop on the path of every single run.
|
|
546
|
+
const xudex_deploy_rules = await import(`${module_path}/deploy_module/xudex.mjs`);
|
|
501
547
|
|
|
502
548
|
const ws_dashboard_msa = await import(`${module_path}/ws_dashboard_module/index_msa.mjs`);
|
|
503
549
|
// Sync twin of the above: the chat-finished alert has to ASK whether the user is
|
|
@@ -2135,9 +2181,11 @@ export const execute_codex_request = async function (req_or_ip, prompt_arg, atta
|
|
|
2135
2181
|
? `You are OpenAI Codex editing a static website on the local Xuda server.
|
|
2136
2182
|
|
|
2137
2183
|
The site's source files are in your current working directory: ${local_cwd}
|
|
2138
|
-
Edit them directly with your normal file tools (read, search, apply patches)
|
|
2184
|
+
Edit them directly with your normal file tools (read, search, apply patches). Everything is local, so do NOT use SSH or scp. Keep changes scoped to the user's request and preserve the rest of the site.
|
|
2185
|
+
|
|
2186
|
+
WRITE FILES WITH apply_patch, NEVER THROUGH THE SHELL. Do not create or overwrite HTML, CSS, JS or JSON with heredocs (cat <<'EOF' > file), echo, printf or sed. Site markup is full of quotes, parentheses and $ characters, and the shell mangles them: the file lands corrupted, or bash dies on "syntax error near unexpected token" and the whole build burns its time budget retrying. Use the shell only to list, read and check files.
|
|
2139
2187
|
|
|
2140
|
-
This is the editable source, not the live site. Do NOT deploy or publish anything
|
|
2188
|
+
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.
|
|
2141
2189
|
|
|
2142
2190
|
User request:
|
|
2143
2191
|
${prompt}`
|
|
@@ -3633,6 +3681,137 @@ export const git_repo_list = async function (req) {
|
|
|
3633
3681
|
}
|
|
3634
3682
|
};
|
|
3635
3683
|
|
|
3684
|
+
// ── UI-228: BYOK provider keys ─────────────────────────────────────────────────────────────
|
|
3685
|
+
// docs/plans/xudex.md 5.2. Claude Code and every engine after it run on the CUSTOMER'S key, not
|
|
3686
|
+
// ours. That removes model cost from our books entirely and turns efficiency work into their
|
|
3687
|
+
// saving rather than our margin, which is a better product and an honest one.
|
|
3688
|
+
//
|
|
3689
|
+
// The key is stored exactly where UI-226 puts a git token, and for exactly the same reason: on a
|
|
3690
|
+
// doc in the ACCOUNT'S project database, never on the app doc in xuda_master, because control DBs
|
|
3691
|
+
// replicate bidirectionally fleet-wide through the master hub and a secret there would be copied
|
|
3692
|
+
// to every region. No read path returns more than the last four characters.
|
|
3693
|
+
const XUDEX_KEY_PROVIDERS = ['anthropic', 'openai'];
|
|
3694
|
+
|
|
3695
|
+
// What a key looks like, checked only enough to catch a paste that obviously is not one. This is
|
|
3696
|
+
// deliberately not strict: providers change their prefixes, and refusing a valid key is worse than
|
|
3697
|
+
// accepting an invalid one that fails clearly on first use.
|
|
3698
|
+
const xudex_key_shape_error = function (provider, api_key) {
|
|
3699
|
+
const key = String(api_key || '').trim();
|
|
3700
|
+
if (key.length < 20) return 'That does not look like an API key. Paste the whole key.';
|
|
3701
|
+
if (provider === 'anthropic' && !key.startsWith('sk-ant-')) {
|
|
3702
|
+
// The single most common wrong thing to paste, so it is worth naming: a Claude Pro or Max
|
|
3703
|
+
// SUBSCRIPTION cannot drive Claude Code on our machines, whatever it can do on a laptop.
|
|
3704
|
+
// Saying it here costs one line and saves a support conversation with every team that tries.
|
|
3705
|
+
return 'That does not look like an Anthropic API key (they start with sk-ant-). A Claude Pro or Max subscription will not work here: Xudex needs an API key with credits on it, from console.anthropic.com.';
|
|
3706
|
+
}
|
|
3707
|
+
if (provider === 'openai' && !key.startsWith('sk-')) return 'That does not look like an OpenAI API key (they start with sk-).';
|
|
3708
|
+
return null;
|
|
3709
|
+
};
|
|
3710
|
+
|
|
3711
|
+
const xudex_key_safe = function (doc) {
|
|
3712
|
+
if (!doc) return null;
|
|
3713
|
+
return { provider: doc.provider, last4: doc.key_last4 || null, added_by_uid: doc.added_by_uid, ts: doc.ts };
|
|
3714
|
+
};
|
|
3715
|
+
|
|
3716
|
+
export const xudex_key_set = async function (req) {
|
|
3717
|
+
const { uid, profile_id, provider, api_key } = req;
|
|
3718
|
+
try {
|
|
3719
|
+
if (!XUDEX_KEY_PROVIDERS.includes(provider)) return { code: -1, data: 'that provider is not supported' };
|
|
3720
|
+
const shape_error = xudex_key_shape_error(provider, api_key);
|
|
3721
|
+
if (shape_error) return { code: -1, data: shape_error };
|
|
3722
|
+
|
|
3723
|
+
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
3724
|
+
// One key per provider per account: adding a new one replaces the old rather than stacking,
|
|
3725
|
+
// so there is never a question of which of two keys a run used.
|
|
3726
|
+
const existing = await db_module.find_app_couch_query(account_profile_info.app_id, { selector: { docType: 'xudex_key', provider, stat: 3 }, limit: 5 });
|
|
3727
|
+
for (const doc of existing?.docs || []) {
|
|
3728
|
+
doc.stat = 4;
|
|
3729
|
+
doc.ts = Date.now();
|
|
3730
|
+
await db_module.save_app_couch_doc(account_profile_info.app_id, doc);
|
|
3731
|
+
}
|
|
3732
|
+
|
|
3733
|
+
const key_doc = {
|
|
3734
|
+
_id: await _common.xuda_get_uuid('xudex_key'),
|
|
3735
|
+
docType: 'xudex_key',
|
|
3736
|
+
stat: 3,
|
|
3737
|
+
provider,
|
|
3738
|
+
api_key: String(api_key).trim(),
|
|
3739
|
+
key_last4: String(api_key).trim().slice(-4),
|
|
3740
|
+
added_by_uid: uid,
|
|
3741
|
+
date_created_ts: Date.now(),
|
|
3742
|
+
ts: Date.now(),
|
|
3743
|
+
};
|
|
3744
|
+
await db_module.save_app_couch_doc_native(account_profile_info.app_id, key_doc);
|
|
3745
|
+
return { code: 1, data: { key: xudex_key_safe(key_doc) } };
|
|
3746
|
+
} catch (err) {
|
|
3747
|
+
console.error(`[xudex] key set failed: ${err?.message || err}`);
|
|
3748
|
+
return { code: -1, data: 'could not save that key' };
|
|
3749
|
+
}
|
|
3750
|
+
};
|
|
3751
|
+
|
|
3752
|
+
export const xudex_key_list = async function (req) {
|
|
3753
|
+
const { uid, profile_id } = req;
|
|
3754
|
+
try {
|
|
3755
|
+
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
3756
|
+
const q = await db_module.find_app_couch_query(account_profile_info.app_id, { selector: { docType: 'xudex_key', stat: 3 }, limit: 20 });
|
|
3757
|
+
return { code: 1, data: { keys: (q?.docs || []).map(xudex_key_safe) } };
|
|
3758
|
+
} catch (err) {
|
|
3759
|
+
return { code: -1, data: 'could not read your keys' };
|
|
3760
|
+
}
|
|
3761
|
+
};
|
|
3762
|
+
|
|
3763
|
+
export const xudex_key_delete = async function (req) {
|
|
3764
|
+
const { uid, profile_id, provider } = req;
|
|
3765
|
+
try {
|
|
3766
|
+
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
3767
|
+
const q = await db_module.find_app_couch_query(account_profile_info.app_id, { selector: { docType: 'xudex_key', provider, stat: 3 }, limit: 5 });
|
|
3768
|
+
let removed = 0;
|
|
3769
|
+
for (const doc of q?.docs || []) {
|
|
3770
|
+
doc.stat = 4;
|
|
3771
|
+
doc.api_key = null;
|
|
3772
|
+
doc.ts = Date.now();
|
|
3773
|
+
await db_module.save_app_couch_doc(account_profile_info.app_id, doc);
|
|
3774
|
+
removed++;
|
|
3775
|
+
}
|
|
3776
|
+
return { code: 1, data: { removed } };
|
|
3777
|
+
} catch (err) {
|
|
3778
|
+
return { code: -1, data: 'could not remove that key' };
|
|
3779
|
+
}
|
|
3780
|
+
};
|
|
3781
|
+
|
|
3782
|
+
// Internal: the key itself, for a run that is about to start. The only path that reads it, and it
|
|
3783
|
+
// never leaves this process except as an environment variable on the engine's own child.
|
|
3784
|
+
const xudex_resolve_api_key = async function (uid, profile_id, provider) {
|
|
3785
|
+
try {
|
|
3786
|
+
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
3787
|
+
const q = await db_module.find_app_couch_query(account_profile_info.app_id, { selector: { docType: 'xudex_key', provider, stat: 3 }, limit: 1 });
|
|
3788
|
+
return q?.docs?.[0]?.api_key || null;
|
|
3789
|
+
} catch (err) {
|
|
3790
|
+
return null;
|
|
3791
|
+
}
|
|
3792
|
+
};
|
|
3793
|
+
|
|
3794
|
+
// What the run path asks for: everything needed to launch, or a refusal a customer can act on.
|
|
3795
|
+
// Kept here rather than in xudex_engines.mjs because only this file can read the key store.
|
|
3796
|
+
//
|
|
3797
|
+
// DELIBERATELY NOT EXPORTED. Its return value contains `launch.env`, which holds the customer's
|
|
3798
|
+
// provider API key, and every `export const` in this module gets a generated queue wrapper and is
|
|
3799
|
+
// therefore callable over the broker. Exporting this would put a customer's key one internal
|
|
3800
|
+
// broker call away from anybody, for no benefit at all: the only caller is the run path, which
|
|
3801
|
+
// lives in this same file.
|
|
3802
|
+
const xudex_prepare_engine = async function ({ uid, profile_id, engine, model, resume_session_id, sandbox } = {}) {
|
|
3803
|
+
const descriptor = xudex_engines.get_engine(engine);
|
|
3804
|
+
if (!descriptor) return { code: -1, data: 'That engine is not available.' };
|
|
3805
|
+
const api_key = descriptor.byok ? await xudex_resolve_api_key(uid, profile_id, descriptor.provider) : null;
|
|
3806
|
+
const launch = xudex_engines.prepare_launch({ engine, model, resume_session_id, api_key, sandbox });
|
|
3807
|
+
if (launch.error) return { code: -1, data: launch.error, needs_key: launch.needs_key || null };
|
|
3808
|
+
return { code: 1, launch, has_key: !!api_key };
|
|
3809
|
+
};
|
|
3810
|
+
|
|
3811
|
+
export const xudex_engine_list = async function () {
|
|
3812
|
+
return { code: 1, data: { engines: xudex_engines.list_engines() } };
|
|
3813
|
+
};
|
|
3814
|
+
|
|
3636
3815
|
export const git_repo_disconnect = async function (req) {
|
|
3637
3816
|
const { uid, profile_id, repo_id } = req;
|
|
3638
3817
|
try {
|
|
@@ -3728,6 +3907,234 @@ const ensure_git_working_copy = async function ({ uid, repo, conversation_id })
|
|
|
3728
3907
|
};
|
|
3729
3908
|
};
|
|
3730
3909
|
|
|
3910
|
+
// ── UI-228: the bare mirror ────────────────────────────────────────────────────────────────
|
|
3911
|
+
// See xudex_mirror.mjs for why this exists: it is what lets a working copy live on a machine that
|
|
3912
|
+
// never holds the customer's token. git_exec is handed over rather than re-implemented, because it is
|
|
3913
|
+
// the only thing here that knows how to give git a credential without it landing in an argv.
|
|
3914
|
+
// UI-228: the VM substrate, registered as the second implementation of the runtime interface.
|
|
3915
|
+
// It reaches the machine over the Proxmox guest agent rather than SSH, because a xudex box is
|
|
3916
|
+
// app_type 'vps' and customer VPS on this platform are keyless by design. See xudex_vm.mjs.
|
|
3917
|
+
//
|
|
3918
|
+
// `resolve_machine` and the Proxmox client are injected rather than imported: only deploy_module
|
|
3919
|
+
// owns Proxmox, and asking it over the broker keeps that ownership where it belongs.
|
|
3920
|
+
const { create_vm_substrate: _create_xudex_vm } = await import('./xudex_vm.mjs');
|
|
3921
|
+
xudex_runtime.register(
|
|
3922
|
+
_create_xudex_vm({
|
|
3923
|
+
pve_request: async (node_doc, method, url, body) => {
|
|
3924
|
+
const ret = await deploy_ms.proxmox_api_request({ node_doc, method, url, body });
|
|
3925
|
+
if (!ret || ret.code < 0) throw new Error(ret?.data || 'proxmox request failed');
|
|
3926
|
+
return ret.data;
|
|
3927
|
+
},
|
|
3928
|
+
resolve_machine: async ({ uid, app_id }) => {
|
|
3929
|
+
const ret = await deploy_ms.xudex_machine_for({ uid, app_id });
|
|
3930
|
+
return ret?.code > 0 ? ret.data : null;
|
|
3931
|
+
},
|
|
3932
|
+
}),
|
|
3933
|
+
);
|
|
3934
|
+
|
|
3935
|
+
// One line at boot so a box can be asked what it can actually run without adding an endpoint for
|
|
3936
|
+
// it. Logged HERE rather than beside the runtime's construction: the VM substrate registers at this
|
|
3937
|
+
// point in the file, so a log line further up would always report `local` alone and read like the
|
|
3938
|
+
// VM support had failed to load.
|
|
3939
|
+
//
|
|
3940
|
+
// A substrate that is registered but not available is the normal case, not a fault: `local` is
|
|
3941
|
+
// gated off outside dev on purpose (plan 3.1), and `vm` is unavailable on a box with no Proxmox.
|
|
3942
|
+
console.log(`[xudex] runtime substrates: ${xudex_runtime.list().map((s) => `${s.kind}=${s.available ? 'available' : 'off'}`).join(' ') || 'none'}`);
|
|
3943
|
+
|
|
3944
|
+
const xudex_mirror = _create_xudex_mirror({
|
|
3945
|
+
git_exec,
|
|
3946
|
+
du_mb: async (dir) =>
|
|
3947
|
+
Number(((await run_process('bash', ['-lc', `du -sm ${JSON.stringify(dir)} | cut -f1`], null, { timeout: 60000 })).stdout || '').trim()) || 0,
|
|
3948
|
+
mirror_root: () => _conf.xudex?.mirror_path || path.join(git_repos_root(), '_mirrors'),
|
|
3949
|
+
clone_timeout_ms: GIT_CLONE_TIMEOUT_MS,
|
|
3950
|
+
clone_max_mb: GIT_CLONE_MAX_MB,
|
|
3951
|
+
});
|
|
3952
|
+
|
|
3953
|
+
// UI-228: the whole chain in one place. Deliberately constructed HERE rather than beside the
|
|
3954
|
+
// other xudex imports at the top of the file: it needs `xudex_mirror` and `git_exec`, both of
|
|
3955
|
+
// which are defined further down, and a top-level const that reads them earlier is a temporal
|
|
3956
|
+
// dead zone error that takes the whole module down at boot. `node --check` does not catch it.
|
|
3957
|
+
const { create_runner: _create_xudex_runner } = await import('./xudex_run.mjs');
|
|
3958
|
+
const xudex_runner = _create_xudex_runner({
|
|
3959
|
+
runtime: xudex_runtime,
|
|
3960
|
+
mirror: xudex_mirror,
|
|
3961
|
+
engines: xudex_engines,
|
|
3962
|
+
verify: xudex_verify,
|
|
3963
|
+
tracker_factory: ({ workspace }) => create_xudex_run_tracker({ workspace }),
|
|
3964
|
+
run_process,
|
|
3965
|
+
git_exec,
|
|
3966
|
+
});
|
|
3967
|
+
|
|
3968
|
+
// ── UI-228: the run, as a method ───────────────────────────────────────────────────────────
|
|
3969
|
+
// Everything the runner does not decide: may this account run at all, has it run too much this
|
|
3970
|
+
// month, which repository, which engine, and where the record goes afterwards.
|
|
3971
|
+
|
|
3972
|
+
// The monthly cap from the plan (50 on free, unlimited above it). Counted from the tracker's own
|
|
3973
|
+
// records, which is the second of the three jobs section 9.4 gives that component: one meter, not
|
|
3974
|
+
// a separate counter that can drift away from what actually ran.
|
|
3975
|
+
const xudex_runs_this_month = async function (app_db_id) {
|
|
3976
|
+
const since = new Date();
|
|
3977
|
+
since.setUTCDate(1);
|
|
3978
|
+
since.setUTCHours(0, 0, 0, 0);
|
|
3979
|
+
try {
|
|
3980
|
+
const q = await db_module.find_app_couch_query(app_db_id, { selector: { docType: 'xudex_run', ts: { $gte: since.getTime() } }, limit: 1000 });
|
|
3981
|
+
return (q?.docs || []).length;
|
|
3982
|
+
} catch (err) {
|
|
3983
|
+
// A failed count must not block a paying customer's work. Erring toward letting the run
|
|
3984
|
+
// happen is the right way round: the cap protects margin, and the abuse layers protect the
|
|
3985
|
+
// thing that actually matters.
|
|
3986
|
+
console.warn(`[xudex] run count failed: ${err.message}`);
|
|
3987
|
+
return 0;
|
|
3988
|
+
}
|
|
3989
|
+
};
|
|
3990
|
+
|
|
3991
|
+
export const xudex_run = async function (req) {
|
|
3992
|
+
const { uid, profile_id, app_id, conversation_id, prompt, engine, model } = req;
|
|
3993
|
+
try {
|
|
3994
|
+
if (_conf.xudex?.enabled !== true) return { code: -1, data: 'Xudex is not available yet.' };
|
|
3995
|
+
if (!app_id) return { code: -1, data: 'app_id is required' };
|
|
3996
|
+
if (!prompt || !String(prompt).trim()) return { code: -1, data: 'There is nothing to do: the request was empty.' };
|
|
3997
|
+
|
|
3998
|
+
// 1. Entitlement. The same rules deploy_xudex uses to hand out a machine, asked again here,
|
|
3999
|
+
// because a membership can lapse between provisioning a machine and using it.
|
|
4000
|
+
//
|
|
4001
|
+
// Asked BEFORE resolving the profile's project database, deliberately. That lookup throws on
|
|
4002
|
+
// an account it cannot find, and a throw lands in the catch at the bottom as "please try
|
|
4003
|
+
// again", which is the least useful thing we could say to someone whose real problem is that
|
|
4004
|
+
// their membership does not include Xudex. Cheap checks first, and each with its own answer.
|
|
4005
|
+
let account_doc = null;
|
|
4006
|
+
try {
|
|
4007
|
+
const acct = await db_module.get_couch_doc('xuda_accounts', uid);
|
|
4008
|
+
account_doc = acct?.code > -1 ? acct.data : null;
|
|
4009
|
+
} catch (e) {}
|
|
4010
|
+
if (!account_doc) return { code: -1, data: 'Xuda could not read your account just now. Please try again.' };
|
|
4011
|
+
const ent = xudex_deploy_rules.xudex_entitlement(account_doc);
|
|
4012
|
+
if (!ent.allowed) return { code: -1, data: ent.reason, needs_membership: true };
|
|
4013
|
+
|
|
4014
|
+
// 2. The identity gate. It is SHADOWED today (verify_policy declares xudex_run at level 2 and
|
|
4015
|
+
// the platform enforcement floor is above it), so this reports what it would have refused and
|
|
4016
|
+
// lets the run proceed. When the floor moves, this line starts denying without being touched.
|
|
4017
|
+
try {
|
|
4018
|
+
const gate = await verify_ms.verify_gate_check_for_uid({ data: { uid, product: 'xudex_run' } });
|
|
4019
|
+
if (gate?.code > 0 && gate.data && gate.data.allow === false) {
|
|
4020
|
+
return {
|
|
4021
|
+
code: -412,
|
|
4022
|
+
data: gate.data.message || 'Identity verification is required before running code on Xudex.',
|
|
4023
|
+
error: 'id_verification_required',
|
|
4024
|
+
required_level: gate.data.required_level,
|
|
4025
|
+
level: gate.data.level,
|
|
4026
|
+
missing_claims: gate.data.missing_claims || [],
|
|
4027
|
+
};
|
|
4028
|
+
}
|
|
4029
|
+
} catch (e) {
|
|
4030
|
+
// A verification service that is down must not stop paying customers working.
|
|
4031
|
+
console.warn(`[xudex] gate check failed, allowing: ${e.message}`);
|
|
4032
|
+
}
|
|
4033
|
+
|
|
4034
|
+
// 3. The monthly cap. The project database is resolved here, at the first point that actually
|
|
4035
|
+
// needs it.
|
|
4036
|
+
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
4037
|
+
const cap = Number(ent.flags?.runs_per_month);
|
|
4038
|
+
if (Number.isFinite(cap) && cap > 0) {
|
|
4039
|
+
const used = await xudex_runs_this_month(account_profile_info.app_id);
|
|
4040
|
+
if (used >= cap) {
|
|
4041
|
+
return { code: -1, data: `You have used all ${cap} Xudex runs on your plan this month. They reset at the start of next month, or you can upgrade for unlimited runs.`, cap_reached: true, used, cap };
|
|
4042
|
+
}
|
|
4043
|
+
}
|
|
4044
|
+
|
|
4045
|
+
// 4. The repository. A xudex run without one has nothing to work on, and saying so is better
|
|
4046
|
+
// than starting an engine in an empty directory and letting it improvise.
|
|
4047
|
+
const repo_list = await git_repo_list({ uid, profile_id, app_id });
|
|
4048
|
+
const repo_ref = repo_list?.data?.repos?.[0];
|
|
4049
|
+
if (!repo_ref) return { code: -1, data: 'Connect a repository to this project first, then try again.', needs_repo: true };
|
|
4050
|
+
const repo_full = await load_git_repo(uid, profile_id, repo_ref._id);
|
|
4051
|
+
if (repo_full.error) return repo_full.error;
|
|
4052
|
+
|
|
4053
|
+
// 5. The engine, and its key. A BYOK engine with no key is refused here, before a workspace is
|
|
4054
|
+
// prepared or a machine is woken.
|
|
4055
|
+
const prepared = await xudex_prepare_engine({ uid, profile_id, engine, model, resume_session_id: req.resume_session_id || null });
|
|
4056
|
+
if (prepared.code < 0) return { code: -1, data: prepared.data, needs_key: prepared.needs_key || null };
|
|
4057
|
+
|
|
4058
|
+
// 6. Run it.
|
|
4059
|
+
const emit = typeof req.on_event === 'function' ? req.on_event : null;
|
|
4060
|
+
const ret = await xudex_runner.run({
|
|
4061
|
+
uid,
|
|
4062
|
+
repo: repo_full.repo,
|
|
4063
|
+
project_id: app_id,
|
|
4064
|
+
conversation_id,
|
|
4065
|
+
prompt,
|
|
4066
|
+
launch: prepared.launch,
|
|
4067
|
+
emit,
|
|
4068
|
+
max_repair_attempts: req.max_repair_attempts,
|
|
4069
|
+
verify_ctx: await xudex_verify_context(app_id),
|
|
4070
|
+
});
|
|
4071
|
+
|
|
4072
|
+
// 7. The record. It is the run meter, the abuse evidence and the benchmark sample all at once,
|
|
4073
|
+
// so it is written whether the run succeeded or not.
|
|
4074
|
+
if (ret.record) {
|
|
4075
|
+
try {
|
|
4076
|
+
await db_module.save_app_couch_doc_native(account_profile_info.app_id, { _id: await _common.xuda_get_uuid('xudex_run'), ...ret.record, app_id, uid });
|
|
4077
|
+
} catch (e) {
|
|
4078
|
+
console.error(`[xudex] could not persist the run record: ${e.message}`);
|
|
4079
|
+
}
|
|
4080
|
+
if (ret.record.verdict === 'flag') {
|
|
4081
|
+
// Log-only until the thresholds have been tuned against real builds (9.4). Loud, because
|
|
4082
|
+
// this is the line somebody will grep for when an abuse complaint arrives.
|
|
4083
|
+
console.warn(`[xudex] RUN FLAGGED uid=${uid} app=${app_id} reasons=${(ret.record.reasons || []).join('; ')} enforced=${ret.record.enforced}`);
|
|
4084
|
+
}
|
|
4085
|
+
}
|
|
4086
|
+
|
|
4087
|
+
// 8. Keep the machine awake for as long as it is being used.
|
|
4088
|
+
try {
|
|
4089
|
+
await deploy_msa.xudex_touch({ app_id });
|
|
4090
|
+
} catch (e) {}
|
|
4091
|
+
|
|
4092
|
+
return ret;
|
|
4093
|
+
} catch (err) {
|
|
4094
|
+
console.error(`[xudex] run failed: ${err?.message || err}`);
|
|
4095
|
+
return { code: -1, data: 'That run could not be completed. Please try again.' };
|
|
4096
|
+
}
|
|
4097
|
+
};
|
|
4098
|
+
|
|
4099
|
+
// What the verify loop needs to know about the project: its manifest, its top-level files and
|
|
4100
|
+
// whether dependencies are installed. Read from the working copy rather than assumed, because a
|
|
4101
|
+
// project whose package.json changed between runs plans different gates.
|
|
4102
|
+
const xudex_verify_context = async function (project_id) {
|
|
4103
|
+
const dir = path.join(_conf.xudex?.projects_root || '/srv/xudex', String(project_id));
|
|
4104
|
+
try {
|
|
4105
|
+
const files = await fs.promises.readdir(dir);
|
|
4106
|
+
let manifest = null;
|
|
4107
|
+
if (files.includes('package.json')) {
|
|
4108
|
+
try {
|
|
4109
|
+
manifest = JSON.parse(await fs.promises.readFile(path.join(dir, 'package.json'), 'utf8'));
|
|
4110
|
+
} catch (e) {
|
|
4111
|
+
// A malformed package.json is the project's problem to fix, and the verify loop will say
|
|
4112
|
+
// so through whatever gate trips on it. It is not a reason to refuse the run.
|
|
4113
|
+
console.warn(`[xudex] unreadable package.json in ${project_id}`);
|
|
4114
|
+
}
|
|
4115
|
+
}
|
|
4116
|
+
return {
|
|
4117
|
+
manifest,
|
|
4118
|
+
files,
|
|
4119
|
+
node_modules_present: files.includes('node_modules'),
|
|
4120
|
+
// The runtime gate. Built here rather than inside the verify loop because only this layer
|
|
4121
|
+
// knows which project it is and how to reach its dev server; the loop just calls it and
|
|
4122
|
+
// treats a dead app like any other failing step, which means a broken preview goes back to
|
|
4123
|
+
// the engine through the same repair path as a broken test.
|
|
4124
|
+
runtime_check: xudex_preview.runtime_check({ project_id, manifest, package_manager: detect_pm(files) }),
|
|
4125
|
+
};
|
|
4126
|
+
} catch (err) {
|
|
4127
|
+
return { manifest: null, files: [], node_modules_present: false };
|
|
4128
|
+
}
|
|
4129
|
+
};
|
|
4130
|
+
|
|
4131
|
+
// Same rule the verify loop uses: the lockfile decides, not a preference.
|
|
4132
|
+
const detect_pm = function (files = []) {
|
|
4133
|
+
if (files.includes('pnpm-lock.yaml')) return 'pnpm';
|
|
4134
|
+
if (files.includes('yarn.lock')) return 'yarn';
|
|
4135
|
+
return 'npm';
|
|
4136
|
+
};
|
|
4137
|
+
|
|
3731
4138
|
// ── Status, commit, push, pull request ─────────────────────────────────────────────────────
|
|
3732
4139
|
export const git_repo_status = async function (req) {
|
|
3733
4140
|
const { uid, profile_id, repo_id, conversation_id } = req;
|
|
@@ -3905,6 +4312,16 @@ export const git_open_pr = async function (req) {
|
|
|
3905
4312
|
// run, so this caps OpenAI cost and protects the shared host from spawn floods.
|
|
3906
4313
|
const SW_GEN_RATE_PER_HOUR = _conf.sw_gen_rate_per_hour || 20;
|
|
3907
4314
|
const SW_GEN_MAX_CONCURRENT = _conf.sw_gen_max_concurrent || 2;
|
|
4315
|
+
// Wall-clock budget for the Codex run itself. A site build is not a chat turn:
|
|
4316
|
+
// a from-scratch multi-page site (design system, responsive pass, generated
|
|
4317
|
+
// favicon) routinely ran past the old 240s ceiling, and every one of those came
|
|
4318
|
+
// back as "The AI took too long to respond" with nothing to show. Only safe
|
|
4319
|
+
// alongside the heartbeat in generate_site_draft — see the comment there.
|
|
4320
|
+
const SW_GEN_CODEX_TIMEOUT = _conf.sw_gen_codex_timeout || 480000;
|
|
4321
|
+
// How often the run touches its job while Codex works. Must stay well under
|
|
4322
|
+
// BOTH the inactivity sweeper's job_timeout and the 600s memcached TTL on the
|
|
4323
|
+
// job doc, since a single update_job refreshes both.
|
|
4324
|
+
const SW_GEN_HEARTBEAT_MS = 30000;
|
|
3908
4325
|
const _sw_gen_acquire = async (uid) => {
|
|
3909
4326
|
const now = Date.now();
|
|
3910
4327
|
const rl_key = `sw_gen_rl_${uid}`;
|
|
@@ -4054,7 +4471,33 @@ When done, briefly summarize what you built.`;
|
|
|
4054
4471
|
// Model pick from the generate screen (UI-13 #4): forward only a codex-eligible
|
|
4055
4472
|
// catalog code; anything else (or none) uses the default model.
|
|
4056
4473
|
const picked_model = _is_codex_model(data.model || data.codex_model) ? (data.model || data.codex_model) : null;
|
|
4057
|
-
|
|
4474
|
+
// The Codex step is the long one and it reports nothing while it runs
|
|
4475
|
+
// (stream: false), so the job would sit untouched for its whole duration.
|
|
4476
|
+
// Two things kill a silent job: kill_inactive_jobs fails anything whose
|
|
4477
|
+
// `ts` is older than job_timeout, and the job doc itself is memcached on a
|
|
4478
|
+
// 600s TTL — once that expires, update_job's get_job finds nothing and
|
|
4479
|
+
// returns early, so the finalize at the end of this function lands on a
|
|
4480
|
+
// job that no longer exists and the browser is never told anything. One
|
|
4481
|
+
// beat every 30s refreshes both (set_job rewrites the doc with a fresh
|
|
4482
|
+
// TTL) and doubles as liveness on the wire for the builder's panel. The
|
|
4483
|
+
// sweeper is deliberately left armed rather than opting out with
|
|
4484
|
+
// is_background: if this run truly wedges, the beat stops with it and the
|
|
4485
|
+
// sweeper still closes the job.
|
|
4486
|
+
const beat_until = Date.now() + SW_GEN_CODEX_TIMEOUT + 60000;
|
|
4487
|
+
const beat = setInterval(() => {
|
|
4488
|
+
// Stop beating once the run is past any budget it could legitimately be
|
|
4489
|
+
// using. Beating forever would keep re-arming the job's clock, which is
|
|
4490
|
+
// exactly how a wedged run becomes invisible: the sweeper only fires on
|
|
4491
|
+
// a job that has gone quiet.
|
|
4492
|
+
if (Date.now() > beat_until) return clearInterval(beat);
|
|
4493
|
+
if (job_id) jobs_ms.update_job({ job_id, current_step: 2, current_step_name: 'Generating with AI' }).catch(() => {});
|
|
4494
|
+
}, SW_GEN_HEARTBEAT_MS);
|
|
4495
|
+
let codex_ret;
|
|
4496
|
+
try {
|
|
4497
|
+
codex_ret = await execute_codex_request({ uid, ip: undefined, local_cwd: dir, prompt: built_prompt, stream: false, sandbox: 'workspace-write', codex_timeout: SW_GEN_CODEX_TIMEOUT, codex_model: picked_model });
|
|
4498
|
+
} finally {
|
|
4499
|
+
clearInterval(beat);
|
|
4500
|
+
}
|
|
4058
4501
|
if (!codex_ret || codex_ret.code < 0) {
|
|
4059
4502
|
// On a non-zero codex exit, codex_ret.data is an object whose real reason
|
|
4060
4503
|
// lives in `stderr` (not `message`); only the early guards return a plain
|
|
@@ -4723,6 +5166,51 @@ export const unarchive_ai_chat = async function (req) {
|
|
|
4723
5166
|
}
|
|
4724
5167
|
};
|
|
4725
5168
|
|
|
5169
|
+
// UI-235: a chat is named once, by the model, out of the opening words, and that name is
|
|
5170
|
+
// then the only thing the card, the Recent row and every search hit carry. Until now there
|
|
5171
|
+
// was no way to change it. The rename is recorded on the chat activity trail next to the
|
|
5172
|
+
// "Named by the AI" row it replaces, so the trail says who called it what and when.
|
|
5173
|
+
//
|
|
5174
|
+
// The caller's own copy only. A shared chat draws its title from the OWNER's doc
|
|
5175
|
+
// (get_ai_chat_info resolves the source), so renaming a recipient's pointer would change
|
|
5176
|
+
// nothing on screen and is refused with a sentence saying so rather than silently.
|
|
5177
|
+
export const rename_ai_chat = async function (req) {
|
|
5178
|
+
const { conversation_id, title, uid } = req;
|
|
5179
|
+
|
|
5180
|
+
try {
|
|
5181
|
+
if (!conversation_id) throw new Error('conversation_id is missing');
|
|
5182
|
+
|
|
5183
|
+
const new_title = String(title ?? '')
|
|
5184
|
+
.replace(/\s+/g, ' ')
|
|
5185
|
+
.trim()
|
|
5186
|
+
.slice(0, 120);
|
|
5187
|
+
if (!new_title) throw new Error('A name is required');
|
|
5188
|
+
|
|
5189
|
+
const account_profile_info = await get_active_account_profile_info(uid);
|
|
5190
|
+
const conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_id);
|
|
5191
|
+
|
|
5192
|
+
if (!conversation_doc || conversation_doc.docType !== 'chat_conversation') throw new Error(`chat ${conversation_id} not found`);
|
|
5193
|
+
if (conversation_doc.shared_from_uid) throw new Error('A shared chat is renamed by the person who owns it');
|
|
5194
|
+
if (conversation_doc.uid !== uid && conversation_doc?.account_profile_info?.uid !== uid) throw new Error('Operation not allowed');
|
|
5195
|
+
|
|
5196
|
+
const previous_title = conversation_doc.title || '';
|
|
5197
|
+
if (previous_title === new_title) return { code: 1, data: { conversation_id, title: new_title } };
|
|
5198
|
+
|
|
5199
|
+
conversation_doc.title = new_title;
|
|
5200
|
+
// The model names a new chat a few seconds after it is created, which can land AFTER a
|
|
5201
|
+
// rename typed straight away. This is what tells it to leave the name alone.
|
|
5202
|
+
conversation_doc.title_set_by = 'user';
|
|
5203
|
+
conversation_doc.ts = Date.now();
|
|
5204
|
+
const save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, conversation_doc);
|
|
5205
|
+
|
|
5206
|
+
log_chat_activity(uid, conversation_id, 'renamed', { by: 'user', from: previous_title, title: new_title }, account_profile_info.app_id);
|
|
5207
|
+
|
|
5208
|
+
return { code: 1, data: { conversation_id, title: new_title, save_ret } };
|
|
5209
|
+
} catch (err) {
|
|
5210
|
+
return { code: -3, data: err.message };
|
|
5211
|
+
}
|
|
5212
|
+
};
|
|
5213
|
+
|
|
4726
5214
|
// export const create_studio_app = async function (subject) {
|
|
4727
5215
|
// try {
|
|
4728
5216
|
// let prompt = `
|
|
@@ -5773,6 +6261,44 @@ export const delete_mini_app = async function (req) {
|
|
|
5773
6261
|
}
|
|
5774
6262
|
};
|
|
5775
6263
|
|
|
6264
|
+
// UI-235: the mini app half of the rename. Same card, same kebab, so the same action has to
|
|
6265
|
+
// be there whichever tab it is drawn in. menuName is what every surface shows and menuTitle
|
|
6266
|
+
// is what the app's own header shows, and create_mini_app writes the model's name into both,
|
|
6267
|
+
// so a rename that moved only one of them would leave the app titled its old name inside.
|
|
6268
|
+
export const rename_mini_app = async function (req) {
|
|
6269
|
+
const { prog_id, title, uid } = req;
|
|
6270
|
+
|
|
6271
|
+
try {
|
|
6272
|
+
if (!prog_id) throw new Error('prog_id is missing');
|
|
6273
|
+
|
|
6274
|
+
const new_title = String(title ?? '')
|
|
6275
|
+
.replace(/\s+/g, ' ')
|
|
6276
|
+
.trim()
|
|
6277
|
+
.slice(0, 120);
|
|
6278
|
+
if (!new_title) throw new Error('A name is required');
|
|
6279
|
+
|
|
6280
|
+
const account_profile_info = await get_active_account_profile_info(uid);
|
|
6281
|
+
const prog_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, prog_id);
|
|
6282
|
+
|
|
6283
|
+
if (!prog_doc?.studio_meta?.miniApp) throw new Error(`mini app ${prog_id} not found`);
|
|
6284
|
+
if (prog_doc.studio_meta.shared_from_uid) throw new Error('A shared mini app is renamed by the person who owns it');
|
|
6285
|
+
if (prog_doc.studio_meta.createdByUid !== uid && prog_doc?.studio_meta?.account_profile_info?.uid !== uid) throw new Error('Operation not allowed');
|
|
6286
|
+
|
|
6287
|
+
const previous_title = prog_doc.properties?.menuName || '';
|
|
6288
|
+
if (previous_title === new_title) return { code: 1, data: { prog_id, title: new_title } };
|
|
6289
|
+
|
|
6290
|
+
prog_doc.properties = prog_doc.properties || {};
|
|
6291
|
+
prog_doc.properties.menuName = new_title;
|
|
6292
|
+
prog_doc.properties.menuTitle = new_title;
|
|
6293
|
+
prog_doc.ts = Date.now();
|
|
6294
|
+
const save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, prog_doc);
|
|
6295
|
+
|
|
6296
|
+
return { code: 1, data: { prog_id, title: new_title, save_ret } };
|
|
6297
|
+
} catch (err) {
|
|
6298
|
+
return { code: -9, data: err.message };
|
|
6299
|
+
}
|
|
6300
|
+
};
|
|
6301
|
+
|
|
5776
6302
|
export const start_cli_agent_conversation = async function (req) {
|
|
5777
6303
|
const { uid, agent_id } = req;
|
|
5778
6304
|
if (!uid) return { code: -1, data: 'uid required' };
|
|
@@ -6662,7 +7188,10 @@ export const get_ai_chat_activity = async function (req) {
|
|
|
6662
7188
|
});
|
|
6663
7189
|
// The title only ever differs from the opening words when the AI named it, which is the
|
|
6664
7190
|
// whole reason the row is worth having.
|
|
6665
|
-
|
|
7191
|
+
// UI-235: unless a person renamed it. This row is reconstructed from the title the doc
|
|
7192
|
+
// holds NOW, so after a rename it would put the user's name in the AI's mouth. Nothing is
|
|
7193
|
+
// lost by dropping it: the recorded rename row carries the name it replaced.
|
|
7194
|
+
if (conversation_doc.title_set_by !== 'user' && conversation_doc.title && conversation_doc.prompt && conversation_doc.title !== getFirstNWords(conversation_doc.prompt, 10)) {
|
|
6666
7195
|
add('title_set', conversation_doc.ts || created_ts, { by: 'ai', title: conversation_doc.title });
|
|
6667
7196
|
}
|
|
6668
7197
|
if (conversation_doc?.category_info?.category) add('categorized', conversation_doc.ts || created_ts, { by: 'ai', category: conversation_doc.category_info.category });
|
|
@@ -8534,12 +9063,17 @@ const process_conversation = async function (uid, conversation_id, account_profi
|
|
|
8534
9063
|
const title = await submit_chat_gpt_prompt({ uid, prompt: `create title for the prompt return 5 words result maximum text only without options : ${prompt}`, model: _conf.default_ai_model, metadata: { conversation_id: conversation_doc._id, func: 'create_conversation' }, account_profile_info });
|
|
8535
9064
|
|
|
8536
9065
|
conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_doc._id);
|
|
8537
|
-
|
|
9066
|
+
// UI-235: the name the model comes back with is seconds late, and a user who renamed
|
|
9067
|
+
// the chat in the meantime would watch their name be overwritten. A name typed by a
|
|
9068
|
+
// person wins over a name guessed from the opening words.
|
|
9069
|
+
if (conversation_doc.title_set_by !== 'user') {
|
|
9070
|
+
conversation_doc.title = title.data;
|
|
8538
9071
|
|
|
8539
|
-
|
|
8540
|
-
|
|
8541
|
-
|
|
8542
|
-
|
|
9072
|
+
await db_module.save_app_couch_doc_native(account_profile_info.app_id, conversation_doc);
|
|
9073
|
+
// UI-202: the chat is renamed from the opening words to whatever the model calls it,
|
|
9074
|
+
// which is the one thing about a chat people ask "where did that come from" about.
|
|
9075
|
+
log_chat_activity(uid, conversation_doc._id, 'title_set', { by: 'ai', title: conversation_doc.title }, account_profile_info.app_id);
|
|
9076
|
+
}
|
|
8543
9077
|
}
|
|
8544
9078
|
}
|
|
8545
9079
|
/// categorize prompt
|
|
@@ -10800,6 +11334,68 @@ ${conversation_history || `User (dashboard): ${prompt}`}
|
|
|
10800
11334
|
// opened from the project's own AI panel never takes this path, so VPS work is untouched.
|
|
10801
11335
|
const from_code_surface = get_dashboard_context_obj(req, conversation_doc)?.code_surface === true;
|
|
10802
11336
|
const project_repo = from_code_surface ? (await git_repo_list({ uid, profile_id, app_id: target_app_id })).data?.repos?.[0] : null;
|
|
11337
|
+
|
|
11338
|
+
// ── UI-228: the Xudex route ─────────────────────────────────────────────────────────
|
|
11339
|
+
// The same surface, a different engine underneath. A Xudex run adds what the Code tab
|
|
11340
|
+
// cannot do today: it verifies the change against the project's own build and tests before
|
|
11341
|
+
// answering, hands failures back for a bounded repair, and records what the run cost.
|
|
11342
|
+
//
|
|
11343
|
+
// Behind a config flag, and the fallback is deliberate rather than defensive. The UI-226
|
|
11344
|
+
// path below is dev-verified and working; this one is newer. `route_code_tab` is false in
|
|
11345
|
+
// prod, true on dev, so it can be turned on for real once it has been watched, and turned
|
|
11346
|
+
// off in one config edit if it misbehaves, with no deploy and no code change.
|
|
11347
|
+
if (project_repo && _conf.xudex?.enabled === true && _conf.xudex?.route_code_tab === true) {
|
|
11348
|
+
emitToDashboard('stream_phase', 'Starting Xudex run');
|
|
11349
|
+
const xret = await xudex_run({
|
|
11350
|
+
uid,
|
|
11351
|
+
profile_id,
|
|
11352
|
+
app_id: target_app_id,
|
|
11353
|
+
conversation_id,
|
|
11354
|
+
prompt,
|
|
11355
|
+
engine: req.xudex_engine || null,
|
|
11356
|
+
model: req.codex_model || req.ai_model || null,
|
|
11357
|
+
resume_session_id: conversation_doc.codex_session_id && conversation_doc.codex_session_host === code_run_host() ? conversation_doc.codex_session_id : null,
|
|
11358
|
+
on_event: (event) => {
|
|
11359
|
+
if (event.type === 'phase') emitToDashboard('stream_phase', event.text, { update: true });
|
|
11360
|
+
else if (event.type === 'command') emitToDashboard('stream_phase', `Running ${event.command}`, { update: true });
|
|
11361
|
+
},
|
|
11362
|
+
});
|
|
11363
|
+
|
|
11364
|
+
if (xret.code < 0) {
|
|
11365
|
+
// Refusals here are things the customer can act on (no repository, no API key, out of
|
|
11366
|
+
// runs, membership too low), so they are shown as written rather than flattened into a
|
|
11367
|
+
// generic failure.
|
|
11368
|
+
const msg = typeof xret.data === 'string' ? xret.data : 'That run could not be completed.';
|
|
11369
|
+
emitToDashboard('response_start');
|
|
11370
|
+
streamText(msg);
|
|
11371
|
+
emitToDashboard('stream_end', undefined, { error: true });
|
|
11372
|
+
return await saveAssistantItem(msg, { is_request_error: true });
|
|
11373
|
+
}
|
|
11374
|
+
|
|
11375
|
+
// The engine's words and OUR verdict, kept apart on the way out exactly as they are kept
|
|
11376
|
+
// apart inside the runner: an engine that declares victory over a red build must not be
|
|
11377
|
+
// able to borrow the verification's credibility for it.
|
|
11378
|
+
const parts = [xret.data.message || '', '', xret.data.ok ? `Verified: ${xret.data.verdict}.` : `Not verified: ${xret.data.verdict}.`];
|
|
11379
|
+
if (!xret.data.ok && xret.data.failed) {
|
|
11380
|
+
parts.push('', `The ${xret.data.failed.name} step said:`, '```', xret.data.failed.output || '', '```');
|
|
11381
|
+
if (xret.data.pre_existing_failure) parts.push('', 'This looks like it was already failing before this change, so it may not be caused by what you asked for.');
|
|
11382
|
+
}
|
|
11383
|
+
const answer = parts.join('\n').trim();
|
|
11384
|
+
|
|
11385
|
+
if (xret.data.session_id) {
|
|
11386
|
+
conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_id);
|
|
11387
|
+
conversation_doc.codex_session_id = xret.data.session_id;
|
|
11388
|
+
conversation_doc.codex_session_host = code_run_host();
|
|
11389
|
+
conversation_doc.ts = Date.now();
|
|
11390
|
+
await db_module.save_app_couch_doc_native(account_profile_info.app_id, conversation_doc);
|
|
11391
|
+
}
|
|
11392
|
+
|
|
11393
|
+
emitToDashboard('response_start');
|
|
11394
|
+
streamText(answer);
|
|
11395
|
+
emitToDashboard('stream_end');
|
|
11396
|
+
return await saveAssistantItem(answer, { xudex: true, verified: xret.data.ok, files: xret.data.files, branch: xret.data.branch });
|
|
11397
|
+
}
|
|
11398
|
+
|
|
10803
11399
|
if (project_repo) {
|
|
10804
11400
|
const repo_full = await load_git_repo(uid, profile_id, project_repo._id);
|
|
10805
11401
|
const prepared = repo_full.repo ? await ensure_git_working_copy({ uid, repo: repo_full.repo, conversation_id }) : { error: 'repository not found' };
|
|
@@ -13942,7 +14538,7 @@ const create_and_upload_image_to_drive = async function (drive_type, file_path,
|
|
|
13942
14538
|
};
|
|
13943
14539
|
|
|
13944
14540
|
export const create_avatar = async function (req, job_id, headers) {
|
|
13945
|
-
const { docType, uid, url, _id, name, account_type } = req;
|
|
14541
|
+
const { docType, uid, url, _id, name, account_type, age } = req;
|
|
13946
14542
|
let { metadata } = req;
|
|
13947
14543
|
try {
|
|
13948
14544
|
if (!['account', 'account_profile'].includes(docType)) {
|
|
@@ -13953,6 +14549,14 @@ export const create_avatar = async function (req, job_id, headers) {
|
|
|
13953
14549
|
throw new Error('invalid account_type value');
|
|
13954
14550
|
}
|
|
13955
14551
|
|
|
14552
|
+
// Make older / make younger, from the avatar window. Validated as one of two
|
|
14553
|
+
// words rather than carried through as free text: what the client picks here
|
|
14554
|
+
// ends up in an instruction to an image model, and the wording of that
|
|
14555
|
+
// instruction belongs to this module.
|
|
14556
|
+
if (age && !AVATAR_AGE_PROMPTS[age]) {
|
|
14557
|
+
throw new Error('invalid age value');
|
|
14558
|
+
}
|
|
14559
|
+
|
|
13956
14560
|
if (!url || url.substr(0, 8) !== 'https://') {
|
|
13957
14561
|
throw new Error('invalid url');
|
|
13958
14562
|
}
|
|
@@ -13969,7 +14573,7 @@ export const create_avatar = async function (req, job_id, headers) {
|
|
|
13969
14573
|
metadata = account_info;
|
|
13970
14574
|
}
|
|
13971
14575
|
metadata.is_user = true;
|
|
13972
|
-
return await get_profile_avatar(url, uid, null, account_profile_info, account_type, docType, _id, metadata, undefined, name, account_info.email, job_id, headers);
|
|
14576
|
+
return await get_profile_avatar(url, uid, null, account_profile_info, account_type, docType, _id, metadata, undefined, name, account_info.email, job_id, headers, age);
|
|
13973
14577
|
} catch (err) {
|
|
13974
14578
|
return {
|
|
13975
14579
|
code: -25,
|
|
@@ -14035,12 +14639,70 @@ export const generate_login_screen = async function (req, job_id, headers = {})
|
|
|
14035
14639
|
}
|
|
14036
14640
|
};
|
|
14037
14641
|
|
|
14038
|
-
|
|
14642
|
+
// The stages this run goes through, in the order it goes through them, in the
|
|
14643
|
+
// words the person waiting is shown. They are written onto the job, which is
|
|
14644
|
+
// what carries them to the browser over the job_info socket.
|
|
14645
|
+
//
|
|
14646
|
+
// Boaz, 2026-08-13: "i cant see the progress and also it takes forever". Both
|
|
14647
|
+
// halves of that were true and the first made the second worse: the work is a
|
|
14648
|
+
// download, two vision calls, an optional image-model restoration, a background
|
|
14649
|
+
// removal and an upload, all behind a single job id that reported nothing until
|
|
14650
|
+
// it was finished, so a two minute run and a stuck one looked exactly alike.
|
|
14651
|
+
const AVATAR_STEP_READ = 'Reading your picture';
|
|
14652
|
+
const AVATAR_STEP_CHECK = 'Checking the picture';
|
|
14653
|
+
const AVATAR_STEP_RESTORE = 'Restoring the picture';
|
|
14654
|
+
const AVATAR_STEP_FACE = 'Turning it to the camera';
|
|
14655
|
+
const AVATAR_STEP_MAKE = 'Making the portrait';
|
|
14656
|
+
const AVATAR_STEP_SAVE = 'Saving it';
|
|
14657
|
+
const AVATAR_STEPS = [AVATAR_STEP_READ, AVATAR_STEP_CHECK, AVATAR_STEP_RESTORE, AVATAR_STEP_FACE, AVATAR_STEP_MAKE, AVATAR_STEP_SAVE];
|
|
14658
|
+
|
|
14659
|
+
// How big the SOURCE picture is allowed to be on its way through here. The
|
|
14660
|
+
// avatar is rendered at 1024, restoration downsizes to 1536 on its own account,
|
|
14661
|
+
// and nothing in the pipeline reads more detail than that, so carrying a 12
|
|
14662
|
+
// megapixel phone photo the whole way buys nothing but a slow lossless PNG
|
|
14663
|
+
// re-encode, a multi-megabyte upload to the vision model, and a background
|
|
14664
|
+
// removal over four times the pixels that survive to the end.
|
|
14665
|
+
const AVATAR_SOURCE_MAX_DIM = 1536;
|
|
14666
|
+
// What the inspections are shown. They answer "is there a real face here, is it
|
|
14667
|
+
// blurry, is it cropped" (questions a thumbnail settles) and shipping the
|
|
14668
|
+
// picture is the slow half of that call.
|
|
14669
|
+
const AVATAR_INSPECT_MAX_DIM = 768;
|
|
14670
|
+
|
|
14671
|
+
// Boaz, 2026-08-13: "add to this screen make older / younger / regenerate".
|
|
14672
|
+
// The wording lives here rather than travelling from the browser, so the client
|
|
14673
|
+
// picks a variant and never writes an instruction for the image model itself.
|
|
14674
|
+
const AVATAR_PASSPORT_PROMPT =
|
|
14675
|
+
'Return a passport style portrait of the same person on a fully transparent background: head and shoulders only, facing the camera, eyes open, neutral expression, even lighting, no scenery, no props, no text, no border. Keep it unmistakably the same person.';
|
|
14676
|
+
const AVATAR_AGE_PROMPTS = {
|
|
14677
|
+
older: `${AVATAR_PASSPORT_PROMPT} Age them by roughly twenty years: grey the hair, add natural lines and skin texture for their later years. Keep the same face shape, eyes, nose, mouth, hairline, skin tone, gender and clothing.`,
|
|
14678
|
+
younger: `${AVATAR_PASSPORT_PROMPT} Take roughly twenty years off them: smooth age lines, restore younger hair colour and fuller skin. Keep the same face shape, eyes, nose, mouth, hairline, skin tone, gender and clothing.`,
|
|
14679
|
+
};
|
|
14680
|
+
|
|
14681
|
+
export const get_profile_avatar = async function (profile_picture, uid, prompt, account_profile_info, account_type, docType = 'account', _id, metadata = {}, business_size = 'unknown', name, email, job_id, headers, age) {
|
|
14039
14682
|
const tempDir = os.tmpdir();
|
|
14040
14683
|
const uniqueId = Date.now() + '_' + Math.random().toString(36).substring(7);
|
|
14041
14684
|
const tempOutputPath = path.join(tempDir, `output_${uniqueId}.png`);
|
|
14042
14685
|
const { is_user } = metadata;
|
|
14043
14686
|
let filename = email || _id || name;
|
|
14687
|
+
|
|
14688
|
+
// Progress, and the timings behind it. Fire and forget in both directions: a
|
|
14689
|
+
// progress update that fails must never take the avatar down with it, and the
|
|
14690
|
+
// log line is what says WHICH stage is the slow one on any given photo.
|
|
14691
|
+
const started_ts = Date.now();
|
|
14692
|
+
let stage_ts = started_ts;
|
|
14693
|
+
const step = function (current_step_name) {
|
|
14694
|
+
console.log(`[avatar] ${current_step_name} (+${((Date.now() - started_ts) / 1000).toFixed(1)}s)`);
|
|
14695
|
+
if (!job_id) return;
|
|
14696
|
+
jobs_ms.update_job({ job_id, steps: AVATAR_STEPS, total_steps: AVATAR_STEPS.length, current_step_name }).catch(() => {});
|
|
14697
|
+
};
|
|
14698
|
+
const mark = function (what) {
|
|
14699
|
+
console.log(`[avatar] ${what} took ${((Date.now() - stage_ts) / 1000).toFixed(1)}s`);
|
|
14700
|
+
stage_ts = Date.now();
|
|
14701
|
+
};
|
|
14702
|
+
|
|
14703
|
+
// An age variant is a prompted edit of the picture the caller handed us, so
|
|
14704
|
+
// it rides the same route the legacy `prompt` argument already had.
|
|
14705
|
+
if (!prompt && AVATAR_AGE_PROMPTS[age]) prompt = AVATAR_AGE_PROMPTS[age];
|
|
14044
14706
|
// inspect_profile_picture tests ret.code before reading it; this one did not.
|
|
14045
14707
|
// submit_chat_gpt_prompt reports a FAILED call as
|
|
14046
14708
|
// { code: -5, data: err.message }, putting prose in the very field the success
|
|
@@ -14068,7 +14730,10 @@ export const get_profile_avatar = async function (profile_picture, uid, prompt,
|
|
|
14068
14730
|
needs_restoration: false,
|
|
14069
14731
|
};
|
|
14070
14732
|
|
|
14071
|
-
|
|
14733
|
+
// Takes a ready data url rather than raw base64: what this call is shown is a
|
|
14734
|
+
// thumbnail (shrink_for_inspection), which is a JPEG, and hard-coding the png
|
|
14735
|
+
// mime type here would describe it wrongly.
|
|
14736
|
+
const inspect_person_in_image = async function (image_url) {
|
|
14072
14737
|
try {
|
|
14073
14738
|
const ret = await submit_chat_gpt_prompt({
|
|
14074
14739
|
uid,
|
|
@@ -14083,7 +14748,7 @@ export const get_profile_avatar = async function (profile_picture, uid, prompt,
|
|
|
14083
14748
|
},
|
|
14084
14749
|
{
|
|
14085
14750
|
type: 'input_image',
|
|
14086
|
-
image_url
|
|
14751
|
+
image_url,
|
|
14087
14752
|
},
|
|
14088
14753
|
],
|
|
14089
14754
|
},
|
|
@@ -14122,9 +14787,12 @@ export const get_profile_avatar = async function (profile_picture, uid, prompt,
|
|
|
14122
14787
|
|
|
14123
14788
|
try {
|
|
14124
14789
|
let imageBase64;
|
|
14125
|
-
|
|
14790
|
+
step(AVATAR_STEP_READ);
|
|
14791
|
+
const image_blob_ret = await get_image_blob_from_downloaded_image(profile_picture, { max_dim: AVATAR_SOURCE_MAX_DIM });
|
|
14792
|
+
mark('reading the picture');
|
|
14126
14793
|
let avatar_source = '';
|
|
14127
14794
|
if (prompt) {
|
|
14795
|
+
step(AVATAR_STEP_MAKE);
|
|
14128
14796
|
const model = transparent_image_model();
|
|
14129
14797
|
let ai_avatar_response;
|
|
14130
14798
|
try {
|
|
@@ -14139,7 +14807,24 @@ export const get_profile_avatar = async function (profile_picture, uid, prompt,
|
|
|
14139
14807
|
throw err;
|
|
14140
14808
|
}
|
|
14141
14809
|
imageBase64 = ai_avatar_response.data[0].b64_json;
|
|
14810
|
+
mark('the image model');
|
|
14142
14811
|
account_msa.record_ai_usage(uid, ai_avatar_response.usage.input_tokens, ai_avatar_response.usage.output_tokens, 'profile avatar', prompt, model, { profile_picture }, account_profile_info);
|
|
14812
|
+
// Framed like every other avatar rather than however the model happened to
|
|
14813
|
+
// compose it. An age variant is the SAME portrait a moment later in life,
|
|
14814
|
+
// so it has to sit in the frame the same way the one it replaces did, or
|
|
14815
|
+
// switching between them reads as two different pictures of two different
|
|
14816
|
+
// people. Background removal only if the model would not draw one through:
|
|
14817
|
+
// edit_image_transparent degrades to an opaque render when the resolved
|
|
14818
|
+
// model refuses a transparent background (UI-138).
|
|
14819
|
+
imageBase64 = await normalizeAuthenticProfileAvatar(imageBase64, {
|
|
14820
|
+
remove_background: !(await has_transparent_pixels(Buffer.from(imageBase64, 'base64'))),
|
|
14821
|
+
});
|
|
14822
|
+
mark('framing the portrait');
|
|
14823
|
+
// An age variant is still the account holder's own face, so it must not be
|
|
14824
|
+
// labelled a stand-in: 'fictional' is what the window warns about, and it
|
|
14825
|
+
// is reserved for a likeness invented because the photo could not carry a
|
|
14826
|
+
// real one.
|
|
14827
|
+
if (age) avatar_source = 'authentic profile';
|
|
14143
14828
|
} else {
|
|
14144
14829
|
imageBase64 = Buffer.from(await image_blob_ret.image_blob.arrayBuffer()).toString('base64');
|
|
14145
14830
|
if (account_type === 'business') {
|
|
@@ -14317,14 +15002,19 @@ export const get_profile_avatar = async function (profile_picture, uid, prompt,
|
|
|
14317
15002
|
avatar_source = 'fictional';
|
|
14318
15003
|
};
|
|
14319
15004
|
// personal account
|
|
14320
|
-
|
|
15005
|
+
step(AVATAR_STEP_CHECK);
|
|
15006
|
+
let person_inspection = await inspect_person_in_image(await shrink_for_inspection(imageBase64));
|
|
15007
|
+
mark('the inspection');
|
|
14321
15008
|
const wants_restoration = person_inspection.needs_restoration || person_inspection.is_too_blurry;
|
|
14322
15009
|
|
|
14323
15010
|
if (wants_restoration && person_inspection.is_real_person_in_picture && !person_inspection.is_too_small) {
|
|
14324
15011
|
console.log('Source photo flagged for restoration, calling gpt-image-1...');
|
|
15012
|
+
step(AVATAR_STEP_RESTORE);
|
|
14325
15013
|
try {
|
|
14326
15014
|
const restored_base64 = await restoreFaceWithOpenAI(imageBase64, { uid, account_profile_info, profile_picture });
|
|
14327
|
-
|
|
15015
|
+
mark('the restoration');
|
|
15016
|
+
const verify = await inspect_person_in_image(await shrink_for_inspection(restored_base64));
|
|
15017
|
+
mark('the restoration check');
|
|
14328
15018
|
if (verify.is_real_person_in_picture && !verify.is_too_blurry && !verify.is_face_too_cropped) {
|
|
14329
15019
|
imageBase64 = restored_base64;
|
|
14330
15020
|
person_inspection = verify;
|
|
@@ -14340,19 +15030,68 @@ export const get_profile_avatar = async function (profile_picture, uid, prompt,
|
|
|
14340
15030
|
const can_create_authentic_avatar = source_quality.is_usable && person_inspection.is_real_person_in_picture && !person_inspection.is_too_blurry && !person_inspection.is_too_small;
|
|
14341
15031
|
|
|
14342
15032
|
if (can_create_authentic_avatar) {
|
|
15033
|
+
// Boaz, 2026-08-13: "the head should face to the camera and not
|
|
15034
|
+
// tilted". The lean is a rotation and is handled in the framing; which
|
|
15035
|
+
// way someone FACES is not. A three-quarter photo has an ear and half
|
|
15036
|
+
// a jaw hidden, and no crop, rotation or cut-out brings them back, so
|
|
15037
|
+
// the only honest way to turn a head towards the camera is to redraw
|
|
15038
|
+
// it. That is what the image model does here, on the one signal that
|
|
15039
|
+
// says it is needed: the inspection above already reports whether the
|
|
15040
|
+
// face is front-facing, and until now nothing read it.
|
|
15041
|
+
//
|
|
15042
|
+
// Deliberately narrow, because it is the expensive path: about a
|
|
15043
|
+
// minute and one image generation against the account's credits,
|
|
15044
|
+
// where the cut-out route takes ten seconds and costs nothing. A photo
|
|
15045
|
+
// that already faces the camera never comes near it.
|
|
15046
|
+
let turned = false;
|
|
15047
|
+
if (!person_inspection.is_front_facing) {
|
|
15048
|
+
step(AVATAR_STEP_FACE);
|
|
15049
|
+
console.log('Photo is not front-facing, redrawing it towards the camera...');
|
|
15050
|
+
const model = transparent_image_model();
|
|
15051
|
+
try {
|
|
15052
|
+
const ai_ret = await edit_image_transparent({
|
|
15053
|
+
model: resolve_ai_model(model),
|
|
15054
|
+
image: await toFile(Buffer.from(imageBase64, 'base64'), 'portrait.png', { type: 'image/png' }),
|
|
15055
|
+
prompt: `${AVATAR_PASSPORT_PROMPT} Turn the head to face the camera straight on. Change nothing else about them: same face, same age, same skin tone, same hair, same clothing, same expression.`,
|
|
15056
|
+
});
|
|
15057
|
+
report_ai_status(model);
|
|
15058
|
+
const drawn = ai_ret?.data?.[0]?.b64_json;
|
|
15059
|
+
if (drawn) {
|
|
15060
|
+
imageBase64 = drawn;
|
|
15061
|
+
turned = true;
|
|
15062
|
+
if (ai_ret.usage) {
|
|
15063
|
+
account_msa.record_ai_usage(uid, ai_ret.usage.input_tokens, ai_ret.usage.output_tokens, 'avatar front facing', 'face the camera', model, { profile_picture }, account_profile_info);
|
|
15064
|
+
}
|
|
15065
|
+
}
|
|
15066
|
+
} catch (err) {
|
|
15067
|
+
// The photo they gave us is still a photo of them. Carrying on
|
|
15068
|
+
// with it beats failing the avatar over the angle of a head.
|
|
15069
|
+
report_ai_status(model, err);
|
|
15070
|
+
console.error('Could not turn the portrait to the camera, using the photo as it is:', err.message);
|
|
15071
|
+
}
|
|
15072
|
+
mark('turning it to the camera');
|
|
15073
|
+
}
|
|
15074
|
+
|
|
14343
15075
|
// No face box any more. The framing is measured off the cut-out
|
|
14344
15076
|
// itself (frameSubjectAsAvatar), so asking a vision model where the
|
|
14345
15077
|
// face is bought nothing but a round trip and a number that could be
|
|
14346
15078
|
// wrong — and being wrong is what cropped a real avatar's head off.
|
|
15079
|
+
step(AVATAR_STEP_MAKE);
|
|
14347
15080
|
imageBase64 = await normalizeAuthenticProfileAvatar(imageBase64, {
|
|
14348
|
-
|
|
15081
|
+
// A redrawn portrait comes back on its own transparent background,
|
|
15082
|
+
// so there is nothing left to remove.
|
|
15083
|
+
remove_background: turned ? !(await has_transparent_pixels(Buffer.from(imageBase64, 'base64'))) : !image_blob_ret.is_transparent,
|
|
14349
15084
|
});
|
|
15085
|
+
mark('the cut-out and framing');
|
|
14350
15086
|
avatar_source = 'authentic profile';
|
|
14351
15087
|
} else {
|
|
15088
|
+
step(AVATAR_STEP_MAKE);
|
|
14352
15089
|
await create_fictional_avatar();
|
|
15090
|
+
mark('the invented likeness');
|
|
14353
15091
|
}
|
|
14354
15092
|
}
|
|
14355
15093
|
}
|
|
15094
|
+
step(AVATAR_STEP_SAVE);
|
|
14356
15095
|
console.log('Normalizing final avatar to 1024 with transparent padding...');
|
|
14357
15096
|
imageBase64 = await normalizeBase64To1024(imageBase64);
|
|
14358
15097
|
|
|
@@ -14373,6 +15112,8 @@ export const get_profile_avatar = async function (profile_picture, uid, prompt,
|
|
|
14373
15112
|
|
|
14374
15113
|
let drive_ret = await drive_ms.upload_drive_file_user({ uid, path: '/Profile Images' }, job_id, headers, file_obj);
|
|
14375
15114
|
drive_ret.data.avatar_source = avatar_source;
|
|
15115
|
+
mark('saving the file');
|
|
15116
|
+
console.log(`[avatar] done in ${((Date.now() - started_ts) / 1000).toFixed(1)}s`);
|
|
14376
15117
|
|
|
14377
15118
|
return drive_ret;
|
|
14378
15119
|
} catch (error) {
|
|
@@ -14380,6 +15121,7 @@ export const get_profile_avatar = async function (profile_picture, uid, prompt,
|
|
|
14380
15121
|
if (error.response?.data) {
|
|
14381
15122
|
console.error('OpenAI API response:', error.response.data);
|
|
14382
15123
|
}
|
|
15124
|
+
console.log(`[avatar] failed after ${((Date.now() - started_ts) / 1000).toFixed(1)}s: ${error.message}`);
|
|
14383
15125
|
return { code: -56, data: error.message };
|
|
14384
15126
|
}
|
|
14385
15127
|
};
|
|
@@ -15136,6 +15878,47 @@ const get_mini_app_info = async function (uid, prog_doc) {
|
|
|
15136
15878
|
return doc;
|
|
15137
15879
|
};
|
|
15138
15880
|
|
|
15881
|
+
// UI-236: the apps grid's live-update path. get_apps answers a fetch and nothing was ever
|
|
15882
|
+
// pushed afterwards, so a mini app renamed, archived or re-thumbnailed elsewhere sat stale
|
|
15883
|
+
// on screen while the chats grid moved on its own. Results.vue has carried the
|
|
15884
|
+
// mini_app_doc_updated handler all along; this is the emit it was waiting for.
|
|
15885
|
+
//
|
|
15886
|
+
// The audience is the account that owns the PROJECT DATABASE the doc lives in, and the
|
|
15887
|
+
// caller passes it (controller_module already holds app_obj.app_uId). It is never read off
|
|
15888
|
+
// the doc, because no field on the doc answers the question: across the 98 mini app studio
|
|
15889
|
+
// docs on dev, doc.uid is unset on every single one, createdByUid is the ORIGINAL author and
|
|
15890
|
+
// points at a different account on 33 of them (a marketplace install or a share keeps the
|
|
15891
|
+
// author's uid), and a shared reference copy carries no createdByUid at all. Emitting to
|
|
15892
|
+
// createdByUid would push one account's row into another account's dashboard.
|
|
15893
|
+
export const emit_mini_app_doc_updated = async function (req) {
|
|
15894
|
+
const { app_id, uid, doc } = req || {};
|
|
15895
|
+
try {
|
|
15896
|
+
if (!app_id || !uid || !doc?._id) return { code: 0, data: 'nothing to emit' };
|
|
15897
|
+
// Exactly what get_apps selects on, so this fires for precisely the docs that can be
|
|
15898
|
+
// rows in that grid and for nothing else.
|
|
15899
|
+
if (doc.docType !== 'studio' || !doc?.studio_meta?.miniApp) return { code: 0, data: 'not a mini app' };
|
|
15900
|
+
|
|
15901
|
+
// The shape get_apps builds its rows from (the shared-copy swap onto the origin doc plus
|
|
15902
|
+
// apply_recipient_marks, user_contact, privilege), because update_fn in the dashboard
|
|
15903
|
+
// Object.assigns this straight into the row it already has, and unshifts it when the row
|
|
15904
|
+
// is new. The raw prog doc would overwrite a good row with a half one.
|
|
15905
|
+
const data = await get_mini_app_info(uid, doc);
|
|
15906
|
+
|
|
15907
|
+
ws_dashboard_msa.emit_message_to_dashboard({
|
|
15908
|
+
service: 'mini_app_doc_updated',
|
|
15909
|
+
to: [uid],
|
|
15910
|
+
data,
|
|
15911
|
+
});
|
|
15912
|
+
|
|
15913
|
+
return { code: 1, data: { prog_id: doc._id, to: uid } };
|
|
15914
|
+
} catch (err) {
|
|
15915
|
+
// Never throws: this hangs off the change reader, and a grid that fails to move is not a
|
|
15916
|
+
// reason to break the checker and history pipeline running beside it.
|
|
15917
|
+
console.error('[emit_mini_app_doc_updated]', doc?._id, err?.message || err);
|
|
15918
|
+
return { code: -1, data: err?.message || String(err) };
|
|
15919
|
+
}
|
|
15920
|
+
};
|
|
15921
|
+
|
|
15139
15922
|
export const pin_ai_chat = async function (req) {
|
|
15140
15923
|
const { uid, conversation_id } = req;
|
|
15141
15924
|
try {
|
|
@@ -15812,6 +16595,55 @@ function normalizeFilename(name) {
|
|
|
15812
16595
|
.replace(/^_+|_+$/g, ''); // trim leading/trailing underscores
|
|
15813
16596
|
}
|
|
15814
16597
|
|
|
16598
|
+
// A thumbnail to ask a vision model about, as a ready data url.
|
|
16599
|
+
//
|
|
16600
|
+
// The inspections decide whether a real face is visible and whether it is
|
|
16601
|
+
// blurry, cropped or degraded, all of which a 768px JPEG answers exactly as
|
|
16602
|
+
// well as the full picture, at a fortieth of the bytes. Uploading the original
|
|
16603
|
+
// was pure waiting: a phone photo re-encoded to lossless PNG is megabytes, and
|
|
16604
|
+
// it went up the wire twice on any photo that got restored.
|
|
16605
|
+
//
|
|
16606
|
+
// Only ever shrinks. A picture already smaller than this is passed through, so
|
|
16607
|
+
// "is it too small" still sees the size the person actually uploaded.
|
|
16608
|
+
async function shrink_for_inspection(base64Image) {
|
|
16609
|
+
try {
|
|
16610
|
+
const jpeg = await sharp(Buffer.from(base64Image, 'base64'))
|
|
16611
|
+
.flatten({ background: { r: 255, g: 255, b: 255 } })
|
|
16612
|
+
.resize({ width: AVATAR_INSPECT_MAX_DIM, height: AVATAR_INSPECT_MAX_DIM, fit: 'inside', withoutEnlargement: true })
|
|
16613
|
+
.jpeg({ quality: 82 })
|
|
16614
|
+
.toBuffer();
|
|
16615
|
+
return `data:image/jpeg;base64,${jpeg.toString('base64')}`;
|
|
16616
|
+
} catch (err) {
|
|
16617
|
+
// Never lose the inspection over the optimisation that was meant to speed
|
|
16618
|
+
// it up: fall back to shipping the picture as it stands.
|
|
16619
|
+
console.error('shrink_for_inspection failed, sending the full picture:', err.message);
|
|
16620
|
+
return `data:image/png;base64,${base64Image}`;
|
|
16621
|
+
}
|
|
16622
|
+
}
|
|
16623
|
+
|
|
16624
|
+
// Does this image actually carry transparency? An alpha channel on its own does
|
|
16625
|
+
// not mean it is used, which is the whole question when deciding whether a
|
|
16626
|
+
// picture still needs its background removed.
|
|
16627
|
+
async function has_transparent_pixels(inputBuffer) {
|
|
16628
|
+
try {
|
|
16629
|
+
const image = sharp(inputBuffer);
|
|
16630
|
+
const metadata = await image.metadata();
|
|
16631
|
+
if (!metadata.hasAlpha) return false;
|
|
16632
|
+
|
|
16633
|
+
const { data, info } = await image.ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
16634
|
+
const channels = info.channels;
|
|
16635
|
+
for (let i = channels - 1; i < data.length; i += channels) {
|
|
16636
|
+
if (data[i] < 255) return true;
|
|
16637
|
+
}
|
|
16638
|
+
return false;
|
|
16639
|
+
} catch (err) {
|
|
16640
|
+
// Unreadable: assume it is opaque, which only means the background removal
|
|
16641
|
+
// runs on something that may not need it. The other way round leaves a
|
|
16642
|
+
// photograph's own background sitting in the avatar.
|
|
16643
|
+
return false;
|
|
16644
|
+
}
|
|
16645
|
+
}
|
|
16646
|
+
|
|
15815
16647
|
async function inspectAvatarSourceQuality(base64Image) {
|
|
15816
16648
|
const inputBuffer = Buffer.from(base64Image, 'base64');
|
|
15817
16649
|
const metadata = await sharp(inputBuffer).metadata();
|
|
@@ -15839,18 +16671,56 @@ async function inspectAvatarSourceQuality(base64Image) {
|
|
|
15839
16671
|
//
|
|
15840
16672
|
// That describes the PRODUCT, not one route to it, so this route — which reaches
|
|
15841
16673
|
// the same result with sharp instead of a model — has to land in the same place.
|
|
15842
|
-
// It did not. It framed to a "passport" geometry of its own invention: the head
|
|
15843
|
-
// at 62% of the frame, sized and positioned from a vision model's face box, with
|
|
15844
|
-
// no margin guaranteed anywhere. Two of the four requirements were missed
|
|
15845
|
-
// outright, and when the face box came back short the crop took the crown off
|
|
15846
|
-
// the top of a real account's avatar.
|
|
15847
16674
|
//
|
|
15848
|
-
// These two numbers are the "top margin" and a little air at the sides
|
|
15849
|
-
//
|
|
15850
|
-
//
|
|
16675
|
+
// These two numbers are the "top margin" and a little air at the sides, used
|
|
16676
|
+
// when the whole SUBJECT is what gets fitted: a cut-out with no readable head in
|
|
16677
|
+
// it, where passport proportions have nothing to measure from.
|
|
16678
|
+
// The margins are what "centered with paddind" (Boaz) means on this route: the
|
|
16679
|
+
// subject is fitted whole, so without them it touches the frame, and a circle
|
|
16680
|
+
// drawn over that clips the very edges of a person who was already fully in
|
|
16681
|
+
// shot. Roomier than they were for that reason.
|
|
15851
16682
|
const AVATAR_SIZE = 1024;
|
|
15852
|
-
const AVATAR_TOP_MARGIN = 0.
|
|
15853
|
-
const AVATAR_SIDE_MARGIN = 0.
|
|
16683
|
+
const AVATAR_TOP_MARGIN = 0.12;
|
|
16684
|
+
const AVATAR_SIDE_MARGIN = 0.08;
|
|
16685
|
+
|
|
16686
|
+
// Boaz, 2026-08-13: "fix the avatar (base on the profile picture) to transform
|
|
16687
|
+
// as passport portraite picture transparent".
|
|
16688
|
+
//
|
|
16689
|
+
// A passport portrait is a stated geometry, not a style: the head is a fixed
|
|
16690
|
+
// share of the frame, sits square in the middle of it, and has air above the
|
|
16691
|
+
// crown. These are the ICAO proportions, at the roomy end of the band because
|
|
16692
|
+
// every surface in Xuda draws this inside a CIRCLE, and a crop tuned for a
|
|
16693
|
+
// rectangle loses its corners there.
|
|
16694
|
+
//
|
|
16695
|
+
// This is the second attempt at passport framing here and it fails differently
|
|
16696
|
+
// from the first. That one sized the head from a VISION MODEL's face box, and
|
|
16697
|
+
// when the box came back short the crop took the crown off the top of a real
|
|
16698
|
+
// account's avatar. Nothing is asked of a model here: the background is already
|
|
16699
|
+
// gone, so the cut-out's own silhouette says where the head is, and the frame is
|
|
16700
|
+
// positioned FROM THE CROWN: the top of the head is placed, never computed as
|
|
16701
|
+
// a leftover, so it cannot be cropped away.
|
|
16702
|
+
// Boaz, 2026-08-13, on the first passport avatar: "the avatar should show head
|
|
16703
|
+
// and shoulders centered with paddind". These numbers are lower than the ICAO
|
|
16704
|
+
// band on purpose, because the frame is not what anyone sees: every surface in
|
|
16705
|
+
// Xuda draws this file inside a CIRCLE, and the inscribed circle of a square
|
|
16706
|
+
// keeps only the middle of it. At a passport-tight 0.6 the shoulders live in the
|
|
16707
|
+
// corners, which is exactly where the circle cuts, so a correctly framed
|
|
16708
|
+
// head-and-shoulders portrait arrived on screen as a head floating on its own.
|
|
16709
|
+
// Sized so the shoulders survive the circle and there is air above the crown.
|
|
16710
|
+
const PASSPORT_HEAD_HEIGHT = 0.46; // crown to chin, as a share of the frame
|
|
16711
|
+
const PASSPORT_CROWN_TOP = 0.17; // air above the crown, same
|
|
16712
|
+
// A head that measures wildly out of proportion means the silhouette was read
|
|
16713
|
+
// wrong (a hat, a raised hand, a segmenter that kept a chair back). Sizing off a
|
|
16714
|
+
// bad measurement is what produces a nose filling the frame, so the scale is
|
|
16715
|
+
// held to a band where the head stays recognisably a head.
|
|
16716
|
+
const PASSPORT_HEAD_WIDTH_MIN = 0.3;
|
|
16717
|
+
const PASSPORT_HEAD_WIDTH_MAX = 0.75;
|
|
16718
|
+
// The shoulder line lands a little below the chin, in the middle of the flare
|
|
16719
|
+
// rather than at its start, so the chin is this far back up towards the crown.
|
|
16720
|
+
// Measured against real cut-outs (a full-length selfie and a tight portrait):
|
|
16721
|
+
// it lands within a few per cent, and what error is left runs SHORT of the chin,
|
|
16722
|
+
// which frames a slightly larger head rather than cropping into the face.
|
|
16723
|
+
const SHOULDER_TO_CHIN = 0.9;
|
|
15854
16724
|
|
|
15855
16725
|
// Frame a background-removed portrait to that spec.
|
|
15856
16726
|
//
|
|
@@ -15861,6 +16731,16 @@ async function frameSubjectAsAvatar(segmentedBuffer) {
|
|
|
15861
16731
|
const bounds = await measureOpaqueBounds(segmentedBuffer);
|
|
15862
16732
|
if (!bounds) return sharp(segmentedBuffer).png({ force: true }).toBuffer();
|
|
15863
16733
|
|
|
16734
|
+
// Passport geometry when the head could be found, which is the normal case
|
|
16735
|
+
// for a portrait. Everything below is the fallback for a cut-out with no
|
|
16736
|
+
// readable head in it, and fits the whole subject instead.
|
|
16737
|
+
const head = resolveHeadGeometry(bounds);
|
|
16738
|
+
console.log(
|
|
16739
|
+
`[avatar] framing ${bounds.width}x${bounds.height}: ${head ? `passport, head ${head.crownY} to ${head.chinY}, centre ${Math.round(head.headCenterX)}` : 'no head measured, fitting the whole subject'}`,
|
|
16740
|
+
);
|
|
16741
|
+
const passport = await framePassportPortrait(segmentedBuffer, bounds);
|
|
16742
|
+
if (passport) return passport;
|
|
16743
|
+
|
|
15864
16744
|
// The subject's own edges rather than every opaque pixel, on BOTH axes. A
|
|
15865
16745
|
// speck above the head shrinks the person to make room for a stray pixel; a
|
|
15866
16746
|
// speck beside them widens the frame and pushes them off centre, which breaks
|
|
@@ -15890,22 +16770,151 @@ async function frameSubjectAsAvatar(segmentedBuffer) {
|
|
|
15890
16770
|
.toBuffer();
|
|
15891
16771
|
}
|
|
15892
16772
|
|
|
16773
|
+
// Passport proportions, measured off the silhouette.
|
|
16774
|
+
//
|
|
16775
|
+
// The frame is placed rather than cropped to: the crown goes at
|
|
16776
|
+
// PASSPORT_CROWN_TOP and the head is scaled to PASSPORT_HEAD_HEIGHT, so the top
|
|
16777
|
+
// of the head has a guaranteed position instead of being whatever survives a
|
|
16778
|
+
// crop. Whatever falls outside the frame after that is shoulder, which is what a
|
|
16779
|
+
// passport photo cuts anyway.
|
|
16780
|
+
//
|
|
16781
|
+
// Returns null when the head could not be measured, so the caller can fall back
|
|
16782
|
+
// to fitting the whole subject.
|
|
16783
|
+
// Was a head measured at all? measureHead reports nothing rather than a guess
|
|
16784
|
+
// when the silhouette does not say, and this is the one place that reading is
|
|
16785
|
+
// turned into "frame it as a passport portrait" or "leave the framing alone".
|
|
16786
|
+
function resolveHeadGeometry(bounds) {
|
|
16787
|
+
if (!bounds || !(bounds.chinY > bounds.crownY) || !(bounds.headWidth > 1)) return null;
|
|
16788
|
+
return { crownY: bounds.crownY, chinY: bounds.chinY, headCenterX: bounds.headCenterX, headWidth: bounds.headWidth };
|
|
16789
|
+
}
|
|
16790
|
+
|
|
16791
|
+
async function framePassportPortrait(segmentedBuffer, bounds) {
|
|
16792
|
+
const head = resolveHeadGeometry(bounds);
|
|
16793
|
+
if (!head) return null;
|
|
16794
|
+
const { crownY, chinY, headCenterX, headWidth } = head;
|
|
16795
|
+
|
|
16796
|
+
// The square of SOURCE pixels that becomes the 1024 frame. Solved for rather
|
|
16797
|
+
// than scaled to, so the crop happens at the picture's own size and the resize
|
|
16798
|
+
// is one bounded step at the end: a small head in a large photo needs a four
|
|
16799
|
+
// times enlargement, and doing that to the whole picture first would mean
|
|
16800
|
+
// resampling a hundred megapixels to throw nearly all of them away.
|
|
16801
|
+
const headHeight = chinY - crownY + 1;
|
|
16802
|
+
let window = headHeight / PASSPORT_HEAD_HEIGHT;
|
|
16803
|
+
|
|
16804
|
+
// Sanity, not taste, and only where it means anything: a head measured off the
|
|
16805
|
+
// SILHOUETTE that lands outside this width band was misread, and the clamp is
|
|
16806
|
+
// what keeps a bad reading from becoming an avatar of somebody's nose. There
|
|
16807
|
+
// is no width to check when the head came from the inspection, which reports a
|
|
16808
|
+
// height and has already been held to it.
|
|
16809
|
+
if (headWidth > 1) {
|
|
16810
|
+
if (headWidth / window > PASSPORT_HEAD_WIDTH_MAX) window = headWidth / PASSPORT_HEAD_WIDTH_MAX;
|
|
16811
|
+
if (headWidth / window < PASSPORT_HEAD_WIDTH_MIN) window = headWidth / PASSPORT_HEAD_WIDTH_MIN;
|
|
16812
|
+
}
|
|
16813
|
+
|
|
16814
|
+
// Never further out than the framing this replaces. Whatever the measurement
|
|
16815
|
+
// does, the worst case is then the whole subject fitted to the frame, which is
|
|
16816
|
+
// what every avatar looked like before passport framing existed. It bounds the
|
|
16817
|
+
// damage of a bad reading to "no better than yesterday" instead of "the face
|
|
16818
|
+
// is a stamp in the middle of a body".
|
|
16819
|
+
const subject_window = (bounds.footY - bounds.crownY + 1) / (1 - AVATAR_TOP_MARGIN);
|
|
16820
|
+
window = Math.max(2, Math.round(Math.min(window, subject_window)));
|
|
16821
|
+
if (!Number.isFinite(window)) return null;
|
|
16822
|
+
|
|
16823
|
+
// Centred on the head, with the crown at its mark.
|
|
16824
|
+
const windowLeft = Math.round(headCenterX - window / 2);
|
|
16825
|
+
const windowTop = Math.round(crownY - window * PASSPORT_CROWN_TOP);
|
|
16826
|
+
|
|
16827
|
+
// The window can hang off any edge: a head near the top of its photo, a
|
|
16828
|
+
// portrait narrower than the frame. So the picture is padded with transparency
|
|
16829
|
+
// first and the window then always lands inside it. Padded rather than nudged
|
|
16830
|
+
// back in: moving the window would put the head somewhere other than where the
|
|
16831
|
+
// spec says it goes, which is the whole point of framing this way.
|
|
16832
|
+
const pad_left = Math.max(0, -windowLeft);
|
|
16833
|
+
const pad_top = Math.max(0, -windowTop);
|
|
16834
|
+
const pad_right = Math.max(0, windowLeft + window - bounds.width);
|
|
16835
|
+
const pad_bottom = Math.max(0, windowTop + window - bounds.height);
|
|
16836
|
+
|
|
16837
|
+
const padded =
|
|
16838
|
+
pad_left || pad_top || pad_right || pad_bottom
|
|
16839
|
+
? await sharp(segmentedBuffer)
|
|
16840
|
+
.ensureAlpha()
|
|
16841
|
+
.extend({ top: pad_top, bottom: pad_bottom, left: pad_left, right: pad_right, background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
|
16842
|
+
.png({ force: true })
|
|
16843
|
+
.toBuffer()
|
|
16844
|
+
: segmentedBuffer;
|
|
16845
|
+
|
|
16846
|
+
return sharp(padded)
|
|
16847
|
+
.ensureAlpha()
|
|
16848
|
+
.extract({ left: windowLeft + pad_left, top: windowTop + pad_top, width: window, height: window })
|
|
16849
|
+
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: 'fill' })
|
|
16850
|
+
.png({ force: true })
|
|
16851
|
+
.toBuffer();
|
|
16852
|
+
}
|
|
16853
|
+
|
|
16854
|
+
// Boaz, 2026-08-13: "the head should face to the camera and not tilted".
|
|
16855
|
+
//
|
|
16856
|
+
// The half of that a crop can honestly do is the LEAN. On a portrait the line
|
|
16857
|
+
// from the middle of the chest to the middle of the head is vertical; when
|
|
16858
|
+
// someone tips their head, or the camera was not level, that line tilts, and the
|
|
16859
|
+
// angle it is off by is the correction. Both centres come out of the measurement
|
|
16860
|
+
// the framing already does, so this costs one rotation and nothing else.
|
|
16861
|
+
//
|
|
16862
|
+
// Deliberately timid. Only acts past a couple of degrees (below that there is
|
|
16863
|
+
// nothing to see and the estimate is noise), never turns more than fifteen (a
|
|
16864
|
+
// bigger reading means the silhouette was misread, not that the person is nearly
|
|
16865
|
+
// horizontal), and rotates about the head rather than the picture, so what is
|
|
16866
|
+
// straightened is the face.
|
|
16867
|
+
//
|
|
16868
|
+
// Which way the person FACES is the other half, and it is not a rotation: a
|
|
16869
|
+
// three-quarter view has one ear hidden, and no amount of turning the picture
|
|
16870
|
+
// brings it back. That needs the image model, which is what the older/younger
|
|
16871
|
+
// route already uses.
|
|
16872
|
+
const AVATAR_TILT_MIN_DEG = 2;
|
|
16873
|
+
const AVATAR_TILT_MAX_DEG = 15;
|
|
16874
|
+
async function straightenSubject(segmentedBuffer, bounds) {
|
|
16875
|
+
try {
|
|
16876
|
+
if (!bounds || bounds.chestCenterX == null || bounds.headCenterX == null || bounds.chinY == null) return segmentedBuffer;
|
|
16877
|
+
const rise = bounds.chestY - (bounds.crownY + bounds.chinY) / 2;
|
|
16878
|
+
if (!(rise > 1)) return segmentedBuffer;
|
|
16879
|
+
const deg = (Math.atan2(bounds.headCenterX - bounds.chestCenterX, rise) * 180) / Math.PI;
|
|
16880
|
+
if (!Number.isFinite(deg) || Math.abs(deg) < AVATAR_TILT_MIN_DEG || Math.abs(deg) > AVATAR_TILT_MAX_DEG) return segmentedBuffer;
|
|
16881
|
+
|
|
16882
|
+
console.log(`[avatar] straightening a ${deg.toFixed(1)} degree lean`);
|
|
16883
|
+
return await sharp(segmentedBuffer)
|
|
16884
|
+
.ensureAlpha()
|
|
16885
|
+
.rotate(-deg, { background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
|
16886
|
+
.png({ force: true })
|
|
16887
|
+
.toBuffer();
|
|
16888
|
+
} catch (err) {
|
|
16889
|
+
// A picture that is not straightened is a picture that is not straightened.
|
|
16890
|
+
// Losing the avatar over it would be the worse trade.
|
|
16891
|
+
console.error('[avatar] could not straighten the subject:', err.message);
|
|
16892
|
+
return segmentedBuffer;
|
|
16893
|
+
}
|
|
16894
|
+
}
|
|
16895
|
+
|
|
15893
16896
|
async function normalizeAuthenticProfileAvatar(base64Image, options = {}) {
|
|
15894
16897
|
const { remove_background = true } = options;
|
|
15895
16898
|
const inputBuffer = Buffer.from(base64Image, 'base64');
|
|
15896
16899
|
const orientedBuffer = await sharp(inputBuffer).rotate().png({ force: true }).toBuffer();
|
|
15897
16900
|
const segmentedBuffer = remove_background ? await removePortraitBackground(orientedBuffer) : orientedBuffer;
|
|
15898
16901
|
|
|
15899
|
-
|
|
16902
|
+
// Level first, frame second: the framing measures a crown, a chin and a
|
|
16903
|
+
// centre, and all three move when the picture turns.
|
|
16904
|
+
const uprightBuffer = await straightenSubject(segmentedBuffer, await measureOpaqueBounds(segmentedBuffer));
|
|
16905
|
+
|
|
16906
|
+
const framedBuffer = await frameSubjectAsAvatar(uprightBuffer);
|
|
15900
16907
|
|
|
15901
16908
|
const subjectBuffer = await sharp(framedBuffer).modulate({ brightness: 1.02, saturation: 1.04 }).sharpen({ sigma: 0.35, m1: 0.4, m2: 0.2 }).png({ quality: 98, compressionLevel: 8, force: true }).toBuffer();
|
|
15902
16909
|
|
|
15903
16910
|
return subjectBuffer.toString('base64');
|
|
15904
16911
|
}
|
|
15905
16912
|
|
|
16913
|
+
// Takes an already oriented PNG buffer. It used to rotate and re-encode one of
|
|
16914
|
+
// its own, which on a multi-megapixel photo is a second full lossless PNG round
|
|
16915
|
+
// trip for a picture its only caller had just produced exactly that way.
|
|
15906
16916
|
async function removePortraitBackground(inputBuffer) {
|
|
15907
|
-
const
|
|
15908
|
-
const inputBlob = new Blob([orientedBuffer], { type: 'image/png' });
|
|
16917
|
+
const inputBlob = new Blob([inputBuffer], { type: 'image/png' });
|
|
15909
16918
|
|
|
15910
16919
|
const outputBlob = await imglyRemoveBackground(inputBlob, {
|
|
15911
16920
|
model: 'medium',
|
|
@@ -15977,21 +16986,237 @@ async function measureOpaqueBounds(inputBuffer) {
|
|
|
15977
16986
|
return to;
|
|
15978
16987
|
};
|
|
15979
16988
|
|
|
16989
|
+
// The subject's own edges, specks excluded. crownY is the top of the head.
|
|
16990
|
+
const crownY = firstIndex(rowCounts, minY, maxY, rowFloor);
|
|
16991
|
+
const footY = lastIndex(rowCounts, minY, maxY, rowFloor);
|
|
16992
|
+
|
|
15980
16993
|
return {
|
|
15981
16994
|
minX,
|
|
15982
16995
|
minY,
|
|
15983
16996
|
maxX,
|
|
15984
16997
|
maxY,
|
|
15985
|
-
|
|
15986
|
-
|
|
15987
|
-
footY: lastIndex(rowCounts, minY, maxY, rowFloor),
|
|
16998
|
+
crownY,
|
|
16999
|
+
footY,
|
|
15988
17000
|
bodyMinX: firstIndex(colCounts, minX, maxX, colFloor),
|
|
15989
17001
|
bodyMaxX: lastIndex(colCounts, minX, maxX, colFloor),
|
|
17002
|
+
...measureHead(data, width, channels, alphaThreshold, rowCounts, crownY, footY),
|
|
15990
17003
|
width,
|
|
15991
17004
|
height,
|
|
15992
17005
|
};
|
|
15993
17006
|
}
|
|
15994
17007
|
|
|
17008
|
+
// The HEAD inside that silhouette: where it ends, and where its middle is.
|
|
17009
|
+
//
|
|
17010
|
+
// There is no face model in this pipeline and one is not worth a megabyte of
|
|
17011
|
+
// download to answer this. A background-removed portrait gives the head away by
|
|
17012
|
+
// its shape: the head is the narrow mass at the top and the shoulders below it
|
|
17013
|
+
// are markedly wider, so the row where the silhouette pinches is the neck, and
|
|
17014
|
+
// the chin is just above it. Exactly the reading the picture window already
|
|
17015
|
+
// makes on the client to centre a face in its circle.
|
|
17016
|
+
//
|
|
17017
|
+
// Returns nothing measurable (chinY null) rather than a guess when the shape
|
|
17018
|
+
// says nothing, so the caller can frame the whole subject instead of trusting a
|
|
17019
|
+
// number it invented.
|
|
17020
|
+
function measureHead(data, width, channels, alphaThreshold, rowCounts, crownY, footY) {
|
|
17021
|
+
const none = { chinY: null, headCenterX: null, headWidth: 0 };
|
|
17022
|
+
const height = footY - crownY + 1;
|
|
17023
|
+
if (height < 8) return none;
|
|
17024
|
+
|
|
17025
|
+
// Smoothed, because the reading below is about the SHAPE of the silhouette,
|
|
17026
|
+
// and a strand of hair or a gap in the cut-out is not part of the shape.
|
|
17027
|
+
const span = Math.max(1, Math.round(height * 0.01));
|
|
17028
|
+
const smooth = new Float64Array(height);
|
|
17029
|
+
for (let i = 0; i < height; i++) {
|
|
17030
|
+
let sum = 0;
|
|
17031
|
+
let n = 0;
|
|
17032
|
+
for (let k = -span; k <= span; k++) {
|
|
17033
|
+
const j = i + k;
|
|
17034
|
+
if (j >= 0 && j < height) {
|
|
17035
|
+
sum += rowCounts[crownY + j];
|
|
17036
|
+
n++;
|
|
17037
|
+
}
|
|
17038
|
+
}
|
|
17039
|
+
smooth[i] = sum / n;
|
|
17040
|
+
}
|
|
17041
|
+
|
|
17042
|
+
// Walk down from the crown looking for the first widen, pinch, widen: out
|
|
17043
|
+
// through the head, in at the neck, out again at the shoulders.
|
|
17044
|
+
//
|
|
17045
|
+
// Deliberately not "the widest row in the top third", which is what this used
|
|
17046
|
+
// to do. That assumes the head is a known share of whatever is in frame, and
|
|
17047
|
+
// it is not: on a photo taken a few steps back the shoulders are already
|
|
17048
|
+
// inside the top third, so they won the "widest row" and the head then
|
|
17049
|
+
// measured as the entire body. That assumption is why one photo framed well
|
|
17050
|
+
// and the next one did not, so it is the assumption that had to go.
|
|
17051
|
+
const DIP = 0.85; // how far below the head's own width the neck has to pinch
|
|
17052
|
+
const RISE = 1.2; // and how much wider the shoulders have to come back
|
|
17053
|
+
let peak = 0;
|
|
17054
|
+
let neck = -1;
|
|
17055
|
+
let neckW = Infinity;
|
|
17056
|
+
for (let i = 0; i < height; i++) {
|
|
17057
|
+
const w = smooth[i];
|
|
17058
|
+
// Still on the way out through the head. A new peak well above a candidate
|
|
17059
|
+
// pinch means that pinch was noise on the way up, so the search restarts.
|
|
17060
|
+
if (w > peak && (neck < 0 || w > neckW * RISE)) {
|
|
17061
|
+
peak = w;
|
|
17062
|
+
neck = -1;
|
|
17063
|
+
neckW = Infinity;
|
|
17064
|
+
}
|
|
17065
|
+
if (peak > 0 && w <= peak * DIP) {
|
|
17066
|
+
if (w < neckW) {
|
|
17067
|
+
neckW = w;
|
|
17068
|
+
neck = crownY + i;
|
|
17069
|
+
} else if (w >= neckW * RISE) {
|
|
17070
|
+
break; // pinched and widened again: that was the neck
|
|
17071
|
+
}
|
|
17072
|
+
}
|
|
17073
|
+
}
|
|
17074
|
+
// A pinch needs something wider BELOW it to be a neck rather than a chin at
|
|
17075
|
+
// the bottom edge of the picture. Measured against the widest row under the
|
|
17076
|
+
// pinch, not the last one: a portrait can taper again at the very bottom (an
|
|
17077
|
+
// arm leaving the frame, a jacket cut off) and reading only the final row
|
|
17078
|
+
// called a perfectly good neck unconfirmed.
|
|
17079
|
+
let below = 0;
|
|
17080
|
+
for (let i = neck > -1 ? neck - crownY + 1 : height; i < height; i++) if (smooth[i] > below) below = smooth[i];
|
|
17081
|
+
const confirmed = neck > -1 && below >= neckW * RISE;
|
|
17082
|
+
|
|
17083
|
+
let widest = 0;
|
|
17084
|
+
let widestAt = crownY;
|
|
17085
|
+
for (let y = crownY; y <= footY; y++) {
|
|
17086
|
+
if (rowCounts[y] > widest) {
|
|
17087
|
+
widest = rowCounts[y];
|
|
17088
|
+
widestAt = y;
|
|
17089
|
+
}
|
|
17090
|
+
}
|
|
17091
|
+
// Is there a BODY below the head at all? The two cases need opposite answers,
|
|
17092
|
+
// so this comes before anything else.
|
|
17093
|
+
//
|
|
17094
|
+
// Read at the BOTTOM EDGE rather than by asking where the widest row is. A
|
|
17095
|
+
// head tapers: the lowest rows of a head-only cut-out are a jaw or a neck, a
|
|
17096
|
+
// fraction of the width at the cheeks. A body does not: shoulders, arms and
|
|
17097
|
+
// clothing run out of the frame at close to full width. Asking instead
|
|
17098
|
+
// "is the widest row near the bottom" reads a portrait whose hair flares at
|
|
17099
|
+
// chest height as head-only, and that mistake frames a whole person as if
|
|
17100
|
+
// they were one head.
|
|
17101
|
+
// Read just ABOVE the bottom edge, and take the widest row there rather than
|
|
17102
|
+
// the average. Straightening a lean rotates the cut-out, which leaves
|
|
17103
|
+
// transparent wedges in the bottom corners: averaging the last rows counts
|
|
17104
|
+
// those wedges as the person getting narrower, and a straightened portrait
|
|
17105
|
+
// then reads as head-only. A band a little higher up is past them.
|
|
17106
|
+
let bottom_width = 0;
|
|
17107
|
+
const bottom_from = Math.max(crownY, footY - Math.round(height * 0.15));
|
|
17108
|
+
const bottom_to = Math.max(bottom_from, footY - Math.round(height * 0.03));
|
|
17109
|
+
for (let y = bottom_from; y <= bottom_to; y++) if (rowCounts[y] > bottom_width) bottom_width = rowCounts[y];
|
|
17110
|
+
// Two thirds of the widest row is the line between the two. Shoulders, arms
|
|
17111
|
+
// and clothing leave the frame at nearly full width; a head is well past its
|
|
17112
|
+
// cheeks by the time it reaches its own last rows, so it never gets there.
|
|
17113
|
+
const has_body = bottom_width >= widest * 0.7;
|
|
17114
|
+
|
|
17115
|
+
let chinY;
|
|
17116
|
+
if (confirmed) {
|
|
17117
|
+
// The chin sits a little ABOVE the narrowest point, which is mid-neck.
|
|
17118
|
+
// Clamped to a band as a backstop: a head is roughly two fifths of a
|
|
17119
|
+
// head-and-shoulders subject and never two thirds, so one freak narrow row
|
|
17120
|
+
// cannot drag the chin down onto the chest.
|
|
17121
|
+
const pinch = Math.min(Math.max(neck - crownY, height * 0.2), height * 0.6);
|
|
17122
|
+
chinY = Math.round(crownY + pinch * 0.94);
|
|
17123
|
+
} else if (has_body) {
|
|
17124
|
+
// No pinch, but there IS a body: hair over the shoulders, a scarf, a collar.
|
|
17125
|
+
// Very common, and the case that broke the first version of this, which read
|
|
17126
|
+
// the whole person as one head and framed a body's worth of picture with the
|
|
17127
|
+
// face a thumbnail in the middle of it.
|
|
17128
|
+
//
|
|
17129
|
+
// Read the SHOULDER LINE instead: the row where the silhouette widens
|
|
17130
|
+
// FASTEST. Above it the outline is a head, which widens gently and then
|
|
17131
|
+
// holds; at the shoulders it flares. The steepest row sits a little below
|
|
17132
|
+
// the chin, in the middle of that flare, so the chin is a fraction of the
|
|
17133
|
+
// way back up towards the crown.
|
|
17134
|
+
//
|
|
17135
|
+
// Measured as a rate of change rather than against a threshold width, and
|
|
17136
|
+
// that is the point: a threshold has to be a fraction of SOMETHING, and
|
|
17137
|
+
// every candidate (the widest row, the frame) changes meaning between a
|
|
17138
|
+
// full-length photo and a tight portrait. A tight portrait is not
|
|
17139
|
+
// hypothetical, it is what this same function produces, so the rule has to
|
|
17140
|
+
// survive being pointed at its own output.
|
|
17141
|
+
const shoulder = steepestWidening(smooth, height);
|
|
17142
|
+
if (shoulder < 0) return none;
|
|
17143
|
+
chinY = Math.round(crownY + Math.min(Math.max(shoulder * SHOULDER_TO_CHIN, height * 0.12), height * 0.7));
|
|
17144
|
+
} else {
|
|
17145
|
+
// A head-only cut-out with no shoulders in frame. The whole subject IS the
|
|
17146
|
+
// head, which is the honest reading and the one that keeps the crown where
|
|
17147
|
+
// it belongs.
|
|
17148
|
+
chinY = footY;
|
|
17149
|
+
}
|
|
17150
|
+
|
|
17151
|
+
// Middle and width of the head itself, measured over the head band only, so
|
|
17152
|
+
// wide shoulders cannot drag either sideways.
|
|
17153
|
+
let sumX = 0;
|
|
17154
|
+
let count = 0;
|
|
17155
|
+
let headMinX = width;
|
|
17156
|
+
let headMaxX = -1;
|
|
17157
|
+
for (let y = crownY; y <= chinY; y++) {
|
|
17158
|
+
for (let x = 0; x < width; x++) {
|
|
17159
|
+
if (data[(y * width + x) * channels + (channels - 1)] > alphaThreshold) {
|
|
17160
|
+
sumX += x;
|
|
17161
|
+
count++;
|
|
17162
|
+
if (x < headMinX) headMinX = x;
|
|
17163
|
+
if (x > headMaxX) headMaxX = x;
|
|
17164
|
+
}
|
|
17165
|
+
}
|
|
17166
|
+
}
|
|
17167
|
+
if (!count || headMaxX < 0) return none;
|
|
17168
|
+
|
|
17169
|
+
// The same reading again over the CHEST, which is what the head is leaning
|
|
17170
|
+
// against. Two centres and the distance between them is a line that should be
|
|
17171
|
+
// vertical on a portrait, and the angle it is off by is the lean.
|
|
17172
|
+
let chestSumX = 0;
|
|
17173
|
+
let chestCount = 0;
|
|
17174
|
+
const chestTop = Math.min(footY, chinY + Math.round((chinY - crownY) * 0.15));
|
|
17175
|
+
const chestBottom = Math.min(footY, chinY + Math.round((chinY - crownY) * 0.6));
|
|
17176
|
+
for (let y = chestTop; y <= chestBottom; y++) {
|
|
17177
|
+
for (let x = 0; x < width; x++) {
|
|
17178
|
+
if (data[(y * width + x) * channels + (channels - 1)] > alphaThreshold) {
|
|
17179
|
+
chestSumX += x;
|
|
17180
|
+
chestCount++;
|
|
17181
|
+
}
|
|
17182
|
+
}
|
|
17183
|
+
}
|
|
17184
|
+
|
|
17185
|
+
// Last check, and the one that catches the reading going wrong rather than
|
|
17186
|
+
// being imprecise: a head is never three times wider than it is tall. When it
|
|
17187
|
+
// measures that way the "shoulder line" was the hair flaring out below the
|
|
17188
|
+
// crown, which happens on a picture that is ALREADY a tight portrait, where
|
|
17189
|
+
// the head fills the frame and its own widening is the steepest thing in it.
|
|
17190
|
+
// Hair keeps this loose: a spread of hair genuinely makes a head wider than it
|
|
17191
|
+
// is tall, so only the absurd is rejected.
|
|
17192
|
+
const headWidth = headMaxX - headMinX + 1;
|
|
17193
|
+
if (chinY - crownY + 1 < headWidth * 0.5) return none;
|
|
17194
|
+
|
|
17195
|
+
const headCenterX = sumX / count;
|
|
17196
|
+
return { chinY, headCenterX, headWidth, chestCenterX: chestCount ? chestSumX / chestCount : null, chestY: chestCount ? (chestTop + chestBottom) / 2 : null };
|
|
17197
|
+
}
|
|
17198
|
+
|
|
17199
|
+
// Where the silhouette widens fastest, as an offset below the crown. Measured
|
|
17200
|
+
// over a window rather than row to row, so a ragged edge cannot win, and
|
|
17201
|
+
// searched between a twelfth and seven tenths of the way down: the chin is never
|
|
17202
|
+
// in the top of the hair, and anything past two thirds is chest.
|
|
17203
|
+
//
|
|
17204
|
+
// Returns -1 if there is nothing to measure.
|
|
17205
|
+
function steepestWidening(smooth, height) {
|
|
17206
|
+
const span = Math.max(2, Math.round(height * 0.03));
|
|
17207
|
+
const at = (i) => smooth[Math.min(height - 1, Math.max(0, i))];
|
|
17208
|
+
let best = 0;
|
|
17209
|
+
let bestAt = -1;
|
|
17210
|
+
for (let i = Math.round(height * 0.08); i <= Math.round(height * 0.7); i++) {
|
|
17211
|
+
const rise = at(i + span) - at(i - span);
|
|
17212
|
+
if (rise > best) {
|
|
17213
|
+
best = rise;
|
|
17214
|
+
bestAt = i;
|
|
17215
|
+
}
|
|
17216
|
+
}
|
|
17217
|
+
return bestAt;
|
|
17218
|
+
}
|
|
17219
|
+
|
|
15995
17220
|
async function restoreFaceWithOpenAI(base64Image, ctx = {}) {
|
|
15996
17221
|
const { uid, account_profile_info, profile_picture } = ctx;
|
|
15997
17222
|
const inputBuffer = Buffer.from(base64Image, 'base64');
|
|
@@ -16213,9 +17438,15 @@ export const inspect_profile_picture = async function (req, job_id, headers) {
|
|
|
16213
17438
|
}
|
|
16214
17439
|
};
|
|
16215
17440
|
|
|
16216
|
-
|
|
16217
|
-
|
|
16218
|
-
|
|
17441
|
+
// `max_dim`, when a caller asks for it, caps the longest side of the picture
|
|
17442
|
+
// this hands back. Nothing is lost by it as long as the caller renders smaller
|
|
17443
|
+
// than the cap; what is saved is real, because everything downstream (a
|
|
17444
|
+
// lossless PNG re-encode, an upload to an image model, a background removal, an
|
|
17445
|
+
// alpha scan) costs in proportion to the pixel count, and a phone camera hands
|
|
17446
|
+
// us sixteen times the pixels a 1024 avatar can use.
|
|
17447
|
+
const get_image_blob_from_downloaded_image = async function (url, { max_dim } = {}) {
|
|
17448
|
+
async function hasTransparentBackground(input) {
|
|
17449
|
+
const image = sharp(input);
|
|
16219
17450
|
const metadata = await image.metadata();
|
|
16220
17451
|
|
|
16221
17452
|
// If the image doesn't have an alpha channel at all → no transparency possible
|
|
@@ -16250,7 +17481,29 @@ const get_image_blob_from_downloaded_image = async function (url) {
|
|
|
16250
17481
|
let buffer;
|
|
16251
17482
|
|
|
16252
17483
|
let is_transparent = false;
|
|
16253
|
-
|
|
17484
|
+
let oversized = false;
|
|
17485
|
+
if (max_dim) {
|
|
17486
|
+
try {
|
|
17487
|
+
const meta = await sharp(filePath).metadata();
|
|
17488
|
+
oversized = Math.max(meta.width || 0, meta.height || 0) > max_dim;
|
|
17489
|
+
} catch (e) {
|
|
17490
|
+
/* unreadable metadata: leave it to the paths below, which throw properly */
|
|
17491
|
+
}
|
|
17492
|
+
}
|
|
17493
|
+
|
|
17494
|
+
if (oversized) {
|
|
17495
|
+
console.log(`Capping ${path.basename(filePath)} to ${max_dim}px...`);
|
|
17496
|
+
// compressionLevel 6 rather than 9: this is a lossless format either way,
|
|
17497
|
+
// so the top level only trades seconds of CPU for bytes on a file that
|
|
17498
|
+
// never leaves this box.
|
|
17499
|
+
buffer = await sharp(filePath)
|
|
17500
|
+
.resize({ width: max_dim, height: max_dim, fit: 'inside', withoutEnlargement: true })
|
|
17501
|
+
.png({ compressionLevel: 6, force: true })
|
|
17502
|
+
.toBuffer();
|
|
17503
|
+
// Measured on the capped copy, which is the one that goes on: sixteen
|
|
17504
|
+
// times fewer pixels to walk for the same answer.
|
|
17505
|
+
is_transparent = await hasTransparentBackground(buffer);
|
|
17506
|
+
} else if (ext === '.png') {
|
|
16254
17507
|
buffer = await fs.promises.readFile(filePath);
|
|
16255
17508
|
is_transparent = await hasTransparentBackground(filePath);
|
|
16256
17509
|
} else {
|