@xuda.io/ai_module 1.1.5614 → 1.1.5616
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 +822 -14
- package/index_ms.mjs +44 -4
- package/index_msa.mjs +44 -0
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -2076,7 +2076,7 @@ export const unarchive_ai_chat = async function (req) {
|
|
|
2076
2076
|
// }
|
|
2077
2077
|
// };
|
|
2078
2078
|
|
|
2079
|
-
const create_image = async function (uid, prompt, model = 'gpt-image-1-mini', size = '1024x1024', n = 1, width = 256, height = 256, metadata = {}, account_profile_info) {
|
|
2079
|
+
const create_image = async function (uid, prompt, model = 'gpt-image-1-mini', size = '1024x1024', n = 1, width = 256, height = 256, metadata = {}, account_profile_info, quality) {
|
|
2080
2080
|
try {
|
|
2081
2081
|
let response;
|
|
2082
2082
|
try {
|
|
@@ -2085,6 +2085,11 @@ const create_image = async function (uid, prompt, model = 'gpt-image-1-mini', si
|
|
|
2085
2085
|
prompt,
|
|
2086
2086
|
size,
|
|
2087
2087
|
n,
|
|
2088
|
+
// Chat-image output is downscaled to width×height (default 256²) right
|
|
2089
|
+
// below, so a high-quality 1024² render is wasted there → default 'low'.
|
|
2090
|
+
// Callers that need a real avatar (e.g. the factual avatar) pass an explicit
|
|
2091
|
+
// `quality` ('medium'). Tunable via _conf.chat_image_quality for the default.
|
|
2092
|
+
quality: quality || _conf.chat_image_quality || 'low',
|
|
2088
2093
|
});
|
|
2089
2094
|
report_ai_status(model);
|
|
2090
2095
|
} catch (err) {
|
|
@@ -8334,7 +8339,9 @@ export const get_profile_picture = async function (uid, account_type = 'business
|
|
|
8334
8339
|
}
|
|
8335
8340
|
|
|
8336
8341
|
if (factual_image_mode) {
|
|
8337
|
-
|
|
8342
|
+
// Factual avatar is a real user-facing profile image, not a chat thumbnail —
|
|
8343
|
+
// generate at medium (overridable via _conf.factual_avatar_quality), not the low default.
|
|
8344
|
+
imageBase64 = await create_image(uid, factual_prompt, undefined, undefined, 1, undefined, undefined, {}, account_profile_info, _conf.factual_avatar_quality || 'medium');
|
|
8338
8345
|
} else {
|
|
8339
8346
|
const model = 'gpt-image-1-mini'; // 'chatgpt-image-latest';
|
|
8340
8347
|
const prompt = `
|
|
@@ -9475,9 +9482,9 @@ async function restoreFaceWithOpenAI(base64Image, ctx = {}) {
|
|
|
9475
9482
|
response = await client.images.edit({
|
|
9476
9483
|
model,
|
|
9477
9484
|
image: imageFile,
|
|
9478
|
-
input_fidelity: 'high',
|
|
9479
|
-
quality: '
|
|
9480
|
-
size: 'auto'
|
|
9485
|
+
input_fidelity: 'high', // identity preservation — keep high (don't degrade)
|
|
9486
|
+
quality: _conf.avatar_image_quality || 'low', // 'low' approved 2026-06-02 (A/B vs high/medium); identity held by input_fidelity:high. ~$0.08 vs $0.23 high. Override via _conf.avatar_image_quality.
|
|
9487
|
+
size: _conf.avatar_image_size || '1024x1024', // was 'auto' (could pick 1536 = ~1.5x output tokens); avatars are square
|
|
9481
9488
|
prompt:
|
|
9482
9489
|
'Restore this portrait photograph. Remove scratches, dust, grain, noise, faded color, and any age-related damage. Recover natural skin texture, sharpen face details, and balance lighting and color. Preserve the exact same person completely unchanged: same face shape, same eye shape and color, same nose, same mouth, same hairline and hair, same skin tone, same age, same gender, same facial expression, and same clothing. Do not alter, beautify, age, or de-age the person. Output a clean, naturally lit photograph of the same person.',
|
|
9483
9490
|
});
|
|
@@ -14131,7 +14138,7 @@ const _widget_verify_google_id_token = async function (id_token) {
|
|
|
14131
14138
|
return payload;
|
|
14132
14139
|
};
|
|
14133
14140
|
|
|
14134
|
-
const _widget_find_or_create_visitor = async function (email, name, google_sub) {
|
|
14141
|
+
const _widget_find_or_create_visitor = async function (email, name, google_sub, picture) {
|
|
14135
14142
|
const ret = await db_module.find_couch_query('xuda_accounts', {
|
|
14136
14143
|
selector: { 'account_info.email': email },
|
|
14137
14144
|
fields: ['_id'],
|
|
@@ -14139,14 +14146,26 @@ const _widget_find_or_create_visitor = async function (email, name, google_sub)
|
|
|
14139
14146
|
});
|
|
14140
14147
|
if (ret.docs && ret.docs.length) {
|
|
14141
14148
|
// If we have a google_sub and the existing account lacks one, attach it.
|
|
14142
|
-
|
|
14149
|
+
// Also seed the Google photo as the profile_picture when the account has no
|
|
14150
|
+
// avatar yet, so avatar generation has a source to work from.
|
|
14151
|
+
if (google_sub || picture) {
|
|
14143
14152
|
try {
|
|
14144
14153
|
const acct_ret = await db_module.get_couch_doc('xuda_accounts', ret.docs[0]._id);
|
|
14145
14154
|
if (acct_ret.code > -1) {
|
|
14146
14155
|
const acct = acct_ret.data;
|
|
14147
|
-
|
|
14156
|
+
let dirty = false;
|
|
14157
|
+
if (google_sub && !acct.google_sub) {
|
|
14148
14158
|
acct.google_sub = google_sub;
|
|
14149
14159
|
acct.google_linked = true;
|
|
14160
|
+
dirty = true;
|
|
14161
|
+
}
|
|
14162
|
+
acct.account_info = acct.account_info || {};
|
|
14163
|
+
if (picture && !acct.account_info.profile_picture && !acct.account_info.profile_avatar) {
|
|
14164
|
+
acct.account_info.profile_picture = picture;
|
|
14165
|
+
acct.account_info.profile_picture_source = 'google';
|
|
14166
|
+
dirty = true;
|
|
14167
|
+
}
|
|
14168
|
+
if (dirty) {
|
|
14150
14169
|
acct.ts = Date.now();
|
|
14151
14170
|
await db_module.save_couch_doc('xuda_accounts', acct);
|
|
14152
14171
|
}
|
|
@@ -14160,6 +14179,13 @@ const _widget_find_or_create_visitor = async function (email, name, google_sub)
|
|
|
14160
14179
|
const secret = _utils.get_id('tmp_pass', 5);
|
|
14161
14180
|
const hash = _utils.hash(secret);
|
|
14162
14181
|
const { first_name, last_name } = _widget_split_name(name);
|
|
14182
|
+
const account_info = { email, username: _utils.get_id('xu'), first_name, last_name, full_name: name };
|
|
14183
|
+
// Seed the Google profile photo so the avatar-generation step has a source.
|
|
14184
|
+
if (picture) {
|
|
14185
|
+
account_info.profile_picture = picture;
|
|
14186
|
+
account_info.profile_picture_source = 'google';
|
|
14187
|
+
account_info.profile_avatar_stat = 0;
|
|
14188
|
+
}
|
|
14163
14189
|
const doc = {
|
|
14164
14190
|
_id: await _common.xuda_get_uuid('account'),
|
|
14165
14191
|
stat: 1,
|
|
@@ -14167,7 +14193,7 @@ const _widget_find_or_create_visitor = async function (email, name, google_sub)
|
|
|
14167
14193
|
source: 'widget',
|
|
14168
14194
|
password: hash,
|
|
14169
14195
|
email,
|
|
14170
|
-
account_info
|
|
14196
|
+
account_info,
|
|
14171
14197
|
};
|
|
14172
14198
|
if (google_sub) {
|
|
14173
14199
|
doc.google_sub = google_sub;
|
|
@@ -14328,7 +14354,7 @@ export const get_widget_embed_snippet = async function (req, job_id, headers) {
|
|
|
14328
14354
|
};
|
|
14329
14355
|
|
|
14330
14356
|
const _widget_signup_core = async function (req, job_id, headers, opts) {
|
|
14331
|
-
const { profile_id, email, name, widget_origin, google_sub, signup_method, visitor_lang } = req;
|
|
14357
|
+
const { profile_id, email, name, widget_origin, google_sub, picture, signup_method, visitor_lang } = req;
|
|
14332
14358
|
const [owner_uid_part, pid_part] = String(profile_id || '').split('.');
|
|
14333
14359
|
if (!owner_uid_part || !pid_part) return { code: -404, data: 'invalid_profile' };
|
|
14334
14360
|
|
|
@@ -14339,8 +14365,17 @@ const _widget_signup_core = async function (req, job_id, headers, opts) {
|
|
|
14339
14365
|
const ap_doc = account_profile_info.account_profile_obj || {};
|
|
14340
14366
|
if (ap_doc.widget_enabled === false) return { code: -404, data: 'widget_disabled' };
|
|
14341
14367
|
|
|
14342
|
-
const visitor_uid = await _widget_find_or_create_visitor(email, name, google_sub);
|
|
14343
|
-
|
|
14368
|
+
const visitor_uid = await _widget_find_or_create_visitor(email, name, google_sub, picture);
|
|
14369
|
+
|
|
14370
|
+
// Kick off persona-avatar generation for the visitor from their Google photo.
|
|
14371
|
+
// Fire-and-forget in the account_module worker; the client polls widget_get_avatar.
|
|
14372
|
+
if (picture) {
|
|
14373
|
+
try {
|
|
14374
|
+
account_msa.ensure_profile_avatar({ uid: visitor_uid }, job_id, headers);
|
|
14375
|
+
} catch (err) {}
|
|
14376
|
+
}
|
|
14377
|
+
|
|
14378
|
+
return await _widget_signup_finalize(visitor_uid, email, name, widget_origin, signup_method, account_profile_info, canonical_owner_uid, canonical_profile_id, ap_doc, job_id, headers, visitor_lang, picture);
|
|
14344
14379
|
};
|
|
14345
14380
|
|
|
14346
14381
|
export const widget_signup = async function (req, job_id, headers) {
|
|
@@ -14375,14 +14410,177 @@ export const widget_signup_google = async function (req, job_id, headers) {
|
|
|
14375
14410
|
.trim();
|
|
14376
14411
|
const name = String(payload.name || payload.email?.split('@')[0] || 'Friend').trim();
|
|
14377
14412
|
if (!email) return { code: -1, data: 'no_email_in_token' };
|
|
14413
|
+
const picture = typeof payload.picture === 'string' ? payload.picture : '';
|
|
14414
|
+
|
|
14415
|
+
return await _widget_signup_core({ profile_id, email, name, widget_origin, visitor_lang, google_sub: payload.sub, picture, signup_method: 'google' }, job_id, headers);
|
|
14416
|
+
} catch (err) {
|
|
14417
|
+
return { code: -1, data: err.message || String(err) };
|
|
14418
|
+
}
|
|
14419
|
+
};
|
|
14420
|
+
|
|
14421
|
+
// Site-level Google sign-in (e.g. the xuda.network topbar). Unlike
|
|
14422
|
+
// widget_signup_google this is NOT tied to a persona/chat — it performs the
|
|
14423
|
+
// same "complete setup of the user in xuda" the widget does (verify the Google
|
|
14424
|
+
// token, then find-or-create + link the visitor's free xuda account by
|
|
14425
|
+
// google_sub, and kick off avatar generation from their Google photo), so a
|
|
14426
|
+
// visitor is fully provisioned at site login, before they ever open a chat.
|
|
14427
|
+
// The chat widget later just links the already-provisioned account to a persona.
|
|
14428
|
+
export const site_signup_google = async function (req, job_id, headers) {
|
|
14429
|
+
try {
|
|
14430
|
+
const { id_token } = req;
|
|
14431
|
+
if (!id_token || typeof id_token !== 'string') return { code: -1, data: 'missing_id_token' };
|
|
14432
|
+
|
|
14433
|
+
let payload;
|
|
14434
|
+
try {
|
|
14435
|
+
payload = await _widget_verify_google_id_token(id_token);
|
|
14436
|
+
} catch (err) {
|
|
14437
|
+
return { code: -401, data: err.message || 'google_token_invalid' };
|
|
14438
|
+
}
|
|
14439
|
+
|
|
14440
|
+
const email = String(payload.email || '')
|
|
14441
|
+
.toLowerCase()
|
|
14442
|
+
.trim();
|
|
14443
|
+
const name = String(payload.name || payload.email?.split('@')[0] || 'Friend').trim();
|
|
14444
|
+
if (!email) return { code: -1, data: 'no_email_in_token' };
|
|
14445
|
+
const picture = typeof payload.picture === 'string' ? payload.picture : '';
|
|
14446
|
+
|
|
14447
|
+
const visitor_uid = await _widget_find_or_create_visitor(email, name, payload.sub, picture);
|
|
14448
|
+
|
|
14449
|
+
// Fire-and-forget avatar generation from the Google photo, same as the widget.
|
|
14450
|
+
if (picture) {
|
|
14451
|
+
try {
|
|
14452
|
+
account_msa.ensure_profile_avatar({ uid: visitor_uid }, job_id, headers);
|
|
14453
|
+
} catch (err) {}
|
|
14454
|
+
}
|
|
14455
|
+
|
|
14456
|
+
const { first_name, last_name } = _widget_split_name(name);
|
|
14457
|
+
return {
|
|
14458
|
+
code: 1,
|
|
14459
|
+
data: {
|
|
14460
|
+
uid: visitor_uid,
|
|
14461
|
+
email,
|
|
14462
|
+
name,
|
|
14463
|
+
first_name,
|
|
14464
|
+
last_name,
|
|
14465
|
+
picture,
|
|
14466
|
+
account_active: true,
|
|
14467
|
+
signin_url: `https://${_conf.domain || 'xuda.ai'}/dashboard/login`,
|
|
14468
|
+
},
|
|
14469
|
+
};
|
|
14470
|
+
} catch (err) {
|
|
14471
|
+
return { code: -1, data: err.message || String(err) };
|
|
14472
|
+
}
|
|
14473
|
+
};
|
|
14474
|
+
|
|
14475
|
+
// Public-profile "Ask Friendship". A visitor on /public_profiles/<target_uid>
|
|
14476
|
+
// signs in with Google (same find-or-create as site_signup_google), then we send
|
|
14477
|
+
// a contact_connection team_request to the profile owner. Auto-accepts (canonical
|
|
14478
|
+
// bidirectional contacts) when the owner has auto_respond enabled; otherwise the
|
|
14479
|
+
// owner gets an invitation email and the request stays pending. Never direct
|
|
14480
|
+
// add_contact — always the team_request invitation flow.
|
|
14481
|
+
export const public_profile_ask_friendship = async function (req, job_id, headers) {
|
|
14482
|
+
try {
|
|
14483
|
+
const { id_token, target_uid } = req;
|
|
14484
|
+
if (!id_token || typeof id_token !== 'string') return { code: -1, data: 'missing_id_token' };
|
|
14485
|
+
if (!target_uid || typeof target_uid !== 'string') return { code: -1, data: 'missing_target_uid' };
|
|
14486
|
+
|
|
14487
|
+
let payload;
|
|
14488
|
+
try {
|
|
14489
|
+
payload = await _widget_verify_google_id_token(id_token);
|
|
14490
|
+
} catch (err) {
|
|
14491
|
+
return { code: -401, data: err.message || 'google_token_invalid' };
|
|
14492
|
+
}
|
|
14493
|
+
const email = String(payload.email || '')
|
|
14494
|
+
.toLowerCase()
|
|
14495
|
+
.trim();
|
|
14496
|
+
const name = String(payload.name || payload.email?.split('@')[0] || 'Friend').trim();
|
|
14497
|
+
if (!email) return { code: -1, data: 'no_email_in_token' };
|
|
14498
|
+
const picture = typeof payload.picture === 'string' ? payload.picture : '';
|
|
14499
|
+
|
|
14500
|
+
// Target must be an active, non-opted-out account.
|
|
14501
|
+
const target_ret = await account_ms.get_account_name({ uid_query: target_uid });
|
|
14502
|
+
if (!target_ret || target_ret.code < 0) return { code: -404, data: 'target_not_found' };
|
|
14503
|
+
const target = target_ret.data || {};
|
|
14504
|
+
if (target.stat !== 3 || target.public_profile_disabled === true) return { code: -404, data: 'target_unavailable' };
|
|
14505
|
+
const target_name = [target.first_name, target.last_name].filter(Boolean).join(' ') || target.username || 'Xuda Member';
|
|
14506
|
+
|
|
14507
|
+
// Find or create the visitor's xuda account (same setup as site_signup_google).
|
|
14508
|
+
const visitor_uid = await _widget_find_or_create_visitor(email, name, payload.sub, picture);
|
|
14509
|
+
if (visitor_uid === target_uid) return { code: -1, data: 'cannot_friend_self' };
|
|
14510
|
+
if (picture) {
|
|
14511
|
+
try {
|
|
14512
|
+
account_msa.ensure_profile_avatar({ uid: visitor_uid }, job_id, headers);
|
|
14513
|
+
} catch (err) {}
|
|
14514
|
+
}
|
|
14515
|
+
|
|
14516
|
+
const { first_name, last_name } = _widget_split_name(name);
|
|
14517
|
+
const visitor_first_name = first_name || '';
|
|
14518
|
+
|
|
14519
|
+
// Dedupe: reuse an existing request from this visitor to this target.
|
|
14520
|
+
const existing_ret = await db_module.find_couch_query('xuda_team', {
|
|
14521
|
+
selector: {
|
|
14522
|
+
docType: 'team_request',
|
|
14523
|
+
access_type: 'contact_connection',
|
|
14524
|
+
team_req_from_uid: visitor_uid,
|
|
14525
|
+
team_req_to_uid: target_uid,
|
|
14526
|
+
contact_source: 'public_profile',
|
|
14527
|
+
},
|
|
14528
|
+
limit: 1,
|
|
14529
|
+
});
|
|
14530
|
+
let team_req_doc = existing_ret?.docs?.[0] || null;
|
|
14378
14531
|
|
|
14379
|
-
|
|
14532
|
+
if (!team_req_doc) {
|
|
14533
|
+
const team_req_id = await _common.xuda_get_uuid('team_req');
|
|
14534
|
+
team_req_doc = {
|
|
14535
|
+
_id: team_req_id,
|
|
14536
|
+
team_req_id,
|
|
14537
|
+
team_req_date: Date.now(),
|
|
14538
|
+
team_req_status_date: null,
|
|
14539
|
+
team_req_to_email: target.email || '',
|
|
14540
|
+
team_req_to_uid: target_uid,
|
|
14541
|
+
team_req_stat: 2,
|
|
14542
|
+
team_req_stat_desc: 'public profile friend request',
|
|
14543
|
+
team_req_from_uid: visitor_uid,
|
|
14544
|
+
docType: 'team_request',
|
|
14545
|
+
team_req_app_id: null,
|
|
14546
|
+
access_type: 'contact_connection',
|
|
14547
|
+
uid: visitor_uid,
|
|
14548
|
+
contact_source: 'public_profile',
|
|
14549
|
+
share_item_id: target_uid,
|
|
14550
|
+
sender_account_info: { email, first_name, last_name, full_name: name },
|
|
14551
|
+
};
|
|
14552
|
+
const tr_save = await db_module.save_couch_doc('xuda_team', team_req_doc);
|
|
14553
|
+
if (tr_save.code < 0) return { code: -1, data: tr_save.data || 'team_request_create_failed' };
|
|
14554
|
+
|
|
14555
|
+
// fire-and-forget — the invitation email shouldn't block the response
|
|
14556
|
+
team_ms.send_access_invitation({ team_req_id: team_req_doc._id }).catch(() => {});
|
|
14557
|
+
}
|
|
14558
|
+
|
|
14559
|
+
let state = team_req_doc.team_req_stat === 3 ? 'open' : team_req_doc.team_req_stat === 4 ? 'declined' : 'pending';
|
|
14560
|
+
|
|
14561
|
+
// Auto-accept when the owner's active profile has auto_respond — performs the
|
|
14562
|
+
// canonical bidirectional contact creation via confirm_team_request.
|
|
14563
|
+
if (state === 'pending') {
|
|
14564
|
+
let auto_accept = false;
|
|
14565
|
+
try {
|
|
14566
|
+
const tgt_ap = await get_active_account_profile_info(target_uid);
|
|
14567
|
+
auto_accept = !!tgt_ap?.account_profile_obj?.auto_respond;
|
|
14568
|
+
} catch (err) {}
|
|
14569
|
+
if (auto_accept) {
|
|
14570
|
+
try {
|
|
14571
|
+
const cret = await team_ms.confirm_team_request({ team_req_id: team_req_doc._id });
|
|
14572
|
+
if (cret && cret.code > -1) state = 'open';
|
|
14573
|
+
} catch (err) {}
|
|
14574
|
+
}
|
|
14575
|
+
}
|
|
14576
|
+
|
|
14577
|
+
return { code: 1, data: { state, visitor_first_name, target_name } };
|
|
14380
14578
|
} catch (err) {
|
|
14381
14579
|
return { code: -1, data: err.message || String(err) };
|
|
14382
14580
|
}
|
|
14383
14581
|
};
|
|
14384
14582
|
|
|
14385
|
-
const _widget_signup_finalize = async function (visitor_uid, email, name, widget_origin, signup_method, account_profile_info, canonical_owner_uid, canonical_profile_id, ap_doc, job_id, headers, visitor_lang) {
|
|
14583
|
+
const _widget_signup_finalize = async function (visitor_uid, email, name, widget_origin, signup_method, account_profile_info, canonical_owner_uid, canonical_profile_id, ap_doc, job_id, headers, visitor_lang, picture) {
|
|
14386
14584
|
try {
|
|
14387
14585
|
let team_req_doc = await _widget_find_existing_team_req(visitor_uid, canonical_profile_id);
|
|
14388
14586
|
let assigned_uid;
|
|
@@ -14494,6 +14692,9 @@ const _widget_signup_finalize = async function (visitor_uid, email, name, widget
|
|
|
14494
14692
|
greeting,
|
|
14495
14693
|
conversation_id,
|
|
14496
14694
|
visitor_first_name,
|
|
14695
|
+
visitor_uid,
|
|
14696
|
+
// 1 = avatar generation kicked off (poll widget_get_avatar); 0 = none in flight
|
|
14697
|
+
avatar_stat: picture ? 1 : 0,
|
|
14497
14698
|
signup_method: signup_method || 'email',
|
|
14498
14699
|
account_active: signup_method === 'google',
|
|
14499
14700
|
signin_url: signup_method === 'google' ? `https://${_conf.domain || 'xuda.ai'}/dashboard/login` : undefined,
|
|
@@ -14504,6 +14705,59 @@ const _widget_signup_finalize = async function (visitor_uid, email, name, widget
|
|
|
14504
14705
|
}
|
|
14505
14706
|
};
|
|
14506
14707
|
|
|
14708
|
+
// Build the avatar-poll payload for an account: generation status plus the
|
|
14709
|
+
// avatar as base64 so an embedding page can draw it to a <canvas> without a
|
|
14710
|
+
// cross-origin taint (drive URLs may not send CORS headers).
|
|
14711
|
+
const _account_avatar_payload = async function (uid) {
|
|
14712
|
+
const acct_ret = await db_module.get_couch_doc('xuda_accounts', uid);
|
|
14713
|
+
if (acct_ret.code < 0 || !acct_ret.data) return null;
|
|
14714
|
+
const info = acct_ret.data.account_info || {};
|
|
14715
|
+
let avatar_b64 = null;
|
|
14716
|
+
if (info.profile_avatar) {
|
|
14717
|
+
try {
|
|
14718
|
+
const blob_ret = await get_image_blob_from_downloaded_image(info.profile_avatar);
|
|
14719
|
+
avatar_b64 = Buffer.from(await blob_ret.image_blob.arrayBuffer()).toString('base64');
|
|
14720
|
+
} catch (err) {}
|
|
14721
|
+
}
|
|
14722
|
+
return {
|
|
14723
|
+
// 0 none · 1 started · 2 generating · 3 done (profile_avatar set)
|
|
14724
|
+
profile_avatar_stat: info.profile_avatar_stat || 0,
|
|
14725
|
+
profile_avatar: info.profile_avatar || null,
|
|
14726
|
+
profile_avatar_error: info.profile_avatar_error || null,
|
|
14727
|
+
avatar_b64,
|
|
14728
|
+
};
|
|
14729
|
+
};
|
|
14730
|
+
|
|
14731
|
+
// Poll the visitor's generated persona avatar after a widget Google signup.
|
|
14732
|
+
// Auth is the widget_token (the session resolves the visitor uid).
|
|
14733
|
+
export const widget_get_avatar = async function (req) {
|
|
14734
|
+
try {
|
|
14735
|
+
const { widget_token } = req || {};
|
|
14736
|
+
const session = await _widget_load_session(widget_token);
|
|
14737
|
+
if (!session) return { code: -401, data: 'invalid_session' };
|
|
14738
|
+
const payload = await _account_avatar_payload(session.visitor_uid);
|
|
14739
|
+
if (!payload) return { code: -1, data: 'visitor_not_found' };
|
|
14740
|
+
return { code: 1, data: payload };
|
|
14741
|
+
} catch (err) {
|
|
14742
|
+
return { code: -1, data: err.message || String(err) };
|
|
14743
|
+
}
|
|
14744
|
+
};
|
|
14745
|
+
|
|
14746
|
+
// Poll the generated avatar after a site_signup_google (no widget session). The
|
|
14747
|
+
// uid is the long random account id returned by site_signup_google — not
|
|
14748
|
+
// enumerable — and only non-sensitive avatar fields are returned.
|
|
14749
|
+
export const site_get_avatar = async function (req) {
|
|
14750
|
+
try {
|
|
14751
|
+
const { uid } = req || {};
|
|
14752
|
+
if (!uid || typeof uid !== 'string') return { code: -1, data: 'missing_uid' };
|
|
14753
|
+
const payload = await _account_avatar_payload(uid);
|
|
14754
|
+
if (!payload) return { code: -1, data: 'account_not_found' };
|
|
14755
|
+
return { code: 1, data: payload };
|
|
14756
|
+
} catch (err) {
|
|
14757
|
+
return { code: -1, data: err.message || String(err) };
|
|
14758
|
+
}
|
|
14759
|
+
};
|
|
14760
|
+
|
|
14507
14761
|
export const widget_send_message = async function (req, job_id, headers) {
|
|
14508
14762
|
try {
|
|
14509
14763
|
const { widget_token, prompt = '', attachments = [] } = req;
|
|
@@ -14629,3 +14883,557 @@ export const widget_get_messages = async function (req, job_id, headers) {
|
|
|
14629
14883
|
return { code: -1, data: err.message || String(err) };
|
|
14630
14884
|
}
|
|
14631
14885
|
};
|
|
14886
|
+
|
|
14887
|
+
// ============================================================================
|
|
14888
|
+
// Static-website generation pipeline (helpers + orchestrator)
|
|
14889
|
+
// ============================================================================
|
|
14890
|
+
//
|
|
14891
|
+
// pick_image_source - three-bucket router (Pexels / gpt-image-1 / chatgpt-image-latest)
|
|
14892
|
+
// pexels_search - first consumer of _conf.pexels.api_key
|
|
14893
|
+
// estimate_static_website_cost - credit pre-check for create + modify
|
|
14894
|
+
// ingest_attachments - categorize user-uploaded files into the output tree + manifest
|
|
14895
|
+
// generate_site_files - planning prompt + per-page HTML + per-image source + SEO files
|
|
14896
|
+
// modify_site_files - modify-by-prompt over existing file set
|
|
14897
|
+
//
|
|
14898
|
+
// Image-source bucket order (cheapest -> most expensive): Pexels (free) ->
|
|
14899
|
+
// gpt-image-1 (cheap AI) -> chatgpt-image-latest (premium AI). Decision is
|
|
14900
|
+
// driven by membership/workspace tier and credit balance so a paid user
|
|
14901
|
+
// running low auto-degrades from premium to cheap rather than failing.
|
|
14902
|
+
|
|
14903
|
+
const LOW_CREDIT_THRESHOLD = _conf.static_website?.low_credit_threshold ?? 10;
|
|
14904
|
+
const HIGH_CREDIT_THRESHOLD = _conf.static_website?.high_credit_threshold ?? 100;
|
|
14905
|
+
|
|
14906
|
+
export const pick_image_source = (account_doc, credit_balance) => {
|
|
14907
|
+
const free_mem = (account_doc?.membership_plan || 'free') === 'free';
|
|
14908
|
+
const free_ws = (account_doc?.ai_workspace_plan || 'free_ai_workspace') === 'free_ai_workspace';
|
|
14909
|
+
const credits = Number(credit_balance ?? 0);
|
|
14910
|
+
const paid_tier = !free_mem || !free_ws;
|
|
14911
|
+
|
|
14912
|
+
// Bucket 1: no AI option (fully-free + no credits)
|
|
14913
|
+
if (!paid_tier && credits < LOW_CREDIT_THRESHOLD) return 'pexels';
|
|
14914
|
+
// Bucket 3: premium, only for paid users with a comfortable buffer
|
|
14915
|
+
if (paid_tier && credits >= HIGH_CREDIT_THRESHOLD) return 'chatgpt-image-latest';
|
|
14916
|
+
// Bucket 2: cheaper AI - free users with credits, paid users running low (auto-degrade)
|
|
14917
|
+
return 'gpt-image-1';
|
|
14918
|
+
};
|
|
14919
|
+
|
|
14920
|
+
export const pexels_search = async (query, n = 1) => {
|
|
14921
|
+
const api_key = _conf.pexels?.api_key;
|
|
14922
|
+
if (!api_key) throw new Error('pexels.api_key not configured');
|
|
14923
|
+
const url = `https://api.pexels.com/v1/search?query=${encodeURIComponent(query)}&per_page=${Math.max(1, Math.min(80, Number(n) || 1))}`;
|
|
14924
|
+
const r = await fetch(url, { headers: { Authorization: api_key } });
|
|
14925
|
+
if (!r.ok) throw new Error(`pexels search failed: ${r.status}`);
|
|
14926
|
+
const j = await r.json();
|
|
14927
|
+
const photos = (j.photos || []).slice(0, n);
|
|
14928
|
+
return Promise.all(
|
|
14929
|
+
photos.map(async (p) => {
|
|
14930
|
+
const img = await fetch(p.src.large || p.src.medium || p.src.original);
|
|
14931
|
+
if (!img.ok) throw new Error(`pexels fetch failed: ${img.status}`);
|
|
14932
|
+
const buf = Buffer.from(await img.arrayBuffer());
|
|
14933
|
+
return {
|
|
14934
|
+
content_b64: buf.toString('base64'),
|
|
14935
|
+
content_type: 'image/jpeg',
|
|
14936
|
+
src_url: p.url,
|
|
14937
|
+
photographer: p.photographer,
|
|
14938
|
+
photographer_url: p.photographer_url,
|
|
14939
|
+
};
|
|
14940
|
+
}),
|
|
14941
|
+
);
|
|
14942
|
+
};
|
|
14943
|
+
|
|
14944
|
+
// Expected per-tier work for the credit estimator. Real generated count is
|
|
14945
|
+
// usually a fraction of the hard cap (page_count_limit in PLAN_OBJ).
|
|
14946
|
+
const EXPECTED_STATIC_WEBSITE_WORK = {
|
|
14947
|
+
landing: { pages: 1, imgs_per_page: 2, seo: 0, css: 1 },
|
|
14948
|
+
full: { pages: 8, imgs_per_page: 3, seo: 0, css: 1 },
|
|
14949
|
+
seo: { pages: 25, imgs_per_page: 3, seo: 2, css: 1 },
|
|
14950
|
+
enterprise: { pages: 100, imgs_per_page: 3, seo: 2, css: 1 },
|
|
14951
|
+
};
|
|
14952
|
+
|
|
14953
|
+
const _image_price = (image_source) => {
|
|
14954
|
+
if (image_source === 'pexels') return 0;
|
|
14955
|
+
const m = _conf.ai_models?.[image_source];
|
|
14956
|
+
return m?.price ?? Math.max(m?.input ?? 0, m?.output ?? 0) ?? 5;
|
|
14957
|
+
};
|
|
14958
|
+
|
|
14959
|
+
const _text_price = () => {
|
|
14960
|
+
const k = _conf.default_ai_model || 'gpt-5.4-mini';
|
|
14961
|
+
const m = _conf.ai_models?.[k];
|
|
14962
|
+
return m?.price ?? 1;
|
|
14963
|
+
};
|
|
14964
|
+
|
|
14965
|
+
export const estimate_static_website_cost = (req) => {
|
|
14966
|
+
const plan_tier = req?.plan_tier;
|
|
14967
|
+
const image_source = req?.image_source || 'gpt-image-1';
|
|
14968
|
+
const mode = req?.mode || 'create';
|
|
14969
|
+
const current_page_count = Number(req?.current_page_count || 0);
|
|
14970
|
+
|
|
14971
|
+
const W = EXPECTED_STATIC_WEBSITE_WORK[plan_tier] || EXPECTED_STATIC_WEBSITE_WORK.landing;
|
|
14972
|
+
const pages =
|
|
14973
|
+
mode === 'modify'
|
|
14974
|
+
? Math.max(1, Math.ceil(current_page_count * 0.3)) // modify touches ~30% by default
|
|
14975
|
+
: W.pages;
|
|
14976
|
+
const text_calls = 1 /* site plan */ + W.css + pages /* per-page HTML */ + W.seo;
|
|
14977
|
+
const image_calls = pages * W.imgs_per_page;
|
|
14978
|
+
|
|
14979
|
+
const text_unit = _text_price();
|
|
14980
|
+
const image_unit = _image_price(image_source);
|
|
14981
|
+
const credits_needed = text_calls * text_unit + image_calls * image_unit;
|
|
14982
|
+
|
|
14983
|
+
return {
|
|
14984
|
+
credits_needed,
|
|
14985
|
+
breakdown: { text_calls, text_unit, image_calls, image_unit, pages, mode },
|
|
14986
|
+
image_source,
|
|
14987
|
+
};
|
|
14988
|
+
};
|
|
14989
|
+
|
|
14990
|
+
// ── Attachment ingestion ────────────────────────────────────────────────
|
|
14991
|
+
//
|
|
14992
|
+
// Shape: [{ filename, mime_type, drive_path? | content_b64? | url? }]
|
|
14993
|
+
// At least one source field must be present. Files land in the deployed site
|
|
14994
|
+
// at a stable /uploads/{img,docs,text,data,files}/{slug} path so the LLM can
|
|
14995
|
+
// reference them by URL in generated HTML.
|
|
14996
|
+
|
|
14997
|
+
const _categorize_attachment = (att) => {
|
|
14998
|
+
const mt = (att?.mime_type || '').toLowerCase();
|
|
14999
|
+
if (mt.startsWith('image/')) return 'image';
|
|
15000
|
+
if (mt === 'application/pdf') return 'pdf';
|
|
15001
|
+
if (mt === 'text/csv' || mt === 'application/vnd.ms-excel') return 'data';
|
|
15002
|
+
if (mt === 'application/json' || mt === 'text/markdown' || mt.startsWith('text/')) return 'text';
|
|
15003
|
+
return 'binary';
|
|
15004
|
+
};
|
|
15005
|
+
|
|
15006
|
+
const _load_attachment_bytes = async (att) => {
|
|
15007
|
+
if (att.content_b64) return Buffer.from(att.content_b64, 'base64');
|
|
15008
|
+
if (att.drive_path) {
|
|
15009
|
+
const abs = path.join(_conf.studio_drive_path, att.drive_path);
|
|
15010
|
+
return await readFile(abs);
|
|
15011
|
+
}
|
|
15012
|
+
if (att.url) {
|
|
15013
|
+
const r = await fetch(att.url);
|
|
15014
|
+
if (!r.ok) throw new Error(`attachment fetch ${att.url}: ${r.status}`);
|
|
15015
|
+
return Buffer.from(await r.arrayBuffer());
|
|
15016
|
+
}
|
|
15017
|
+
throw new Error(`attachment ${att.filename} has no source (drive_path | content_b64 | url)`);
|
|
15018
|
+
};
|
|
15019
|
+
|
|
15020
|
+
export const ingest_attachments = async (attachments = []) => {
|
|
15021
|
+
const out = { manifest: [], file_entries: [], text_corpus: [] };
|
|
15022
|
+
for (const att of attachments) {
|
|
15023
|
+
if (!att || !att.filename) continue;
|
|
15024
|
+
const kind = _categorize_attachment(att);
|
|
15025
|
+
let bytes;
|
|
15026
|
+
try {
|
|
15027
|
+
bytes = await _load_attachment_bytes(att);
|
|
15028
|
+
} catch (err) {
|
|
15029
|
+
console.error('[ingest_attachments] skip', att.filename, err.message);
|
|
15030
|
+
continue;
|
|
15031
|
+
}
|
|
15032
|
+
const slug = String(att.filename)
|
|
15033
|
+
.replace(/[^a-z0-9.-]/gi, '_')
|
|
15034
|
+
.toLowerCase();
|
|
15035
|
+
let public_path;
|
|
15036
|
+
if (kind === 'image') public_path = `/uploads/img/${slug}`;
|
|
15037
|
+
else if (kind === 'pdf') public_path = `/uploads/docs/${slug}`;
|
|
15038
|
+
else if (kind === 'text') public_path = `/uploads/text/${slug}`;
|
|
15039
|
+
else if (kind === 'data') public_path = `/uploads/data/${slug}`;
|
|
15040
|
+
else public_path = `/uploads/files/${slug}`;
|
|
15041
|
+
|
|
15042
|
+
out.file_entries.push({
|
|
15043
|
+
path: public_path,
|
|
15044
|
+
content_b64: bytes.toString('base64'),
|
|
15045
|
+
content_type: att.mime_type || 'application/octet-stream',
|
|
15046
|
+
});
|
|
15047
|
+
|
|
15048
|
+
const manifest_entry = { filename: att.filename, kind, public_path };
|
|
15049
|
+
if (kind === 'text' || kind === 'data') {
|
|
15050
|
+
const text = bytes.toString('utf8').slice(0, 8000);
|
|
15051
|
+
manifest_entry.excerpt = text;
|
|
15052
|
+
out.text_corpus.push({ filename: att.filename, text });
|
|
15053
|
+
}
|
|
15054
|
+
if (kind === 'image') {
|
|
15055
|
+
manifest_entry.usage = `Embed via <img src="${public_path}" alt="..."> on relevant pages.`;
|
|
15056
|
+
}
|
|
15057
|
+
out.manifest.push(manifest_entry);
|
|
15058
|
+
}
|
|
15059
|
+
return out;
|
|
15060
|
+
};
|
|
15061
|
+
|
|
15062
|
+
// ── Generation orchestrator ─────────────────────────────────────────────
|
|
15063
|
+
//
|
|
15064
|
+
// generate_site_files runs the prompt -> plan -> pages -> images -> SEO chain.
|
|
15065
|
+
// Returns a flat [{ path, content_b64, content_type }] list ready for
|
|
15066
|
+
// domains_module.deploy_site.
|
|
15067
|
+
|
|
15068
|
+
const _safe_json_parse = (s) => {
|
|
15069
|
+
if (!s || typeof s !== 'string') return null;
|
|
15070
|
+
// The LLM sometimes wraps JSON in ```json fences despite instructions.
|
|
15071
|
+
const cleaned = s
|
|
15072
|
+
.trim()
|
|
15073
|
+
.replace(/^```(?:json)?\s*/i, '')
|
|
15074
|
+
.replace(/\s*```\s*$/i, '');
|
|
15075
|
+
try {
|
|
15076
|
+
return JSON.parse(cleaned);
|
|
15077
|
+
} catch (_) {
|
|
15078
|
+
return null;
|
|
15079
|
+
}
|
|
15080
|
+
};
|
|
15081
|
+
|
|
15082
|
+
const _chat = async (uid, prompt, account_profile_info, metadata) => {
|
|
15083
|
+
const ret = await submit_chat_gpt_prompt({
|
|
15084
|
+
uid,
|
|
15085
|
+
prompt,
|
|
15086
|
+
model: _conf.default_ai_model,
|
|
15087
|
+
metadata: metadata || {},
|
|
15088
|
+
account_profile_info,
|
|
15089
|
+
});
|
|
15090
|
+
if (ret.code < 0) throw new Error(`LLM call failed: ${ret.data}`);
|
|
15091
|
+
return ret.data || '';
|
|
15092
|
+
};
|
|
15093
|
+
|
|
15094
|
+
const _site_planning_prompt = (user_prompt, attachments_manifest, plan_tier, page_count_limit) => {
|
|
15095
|
+
const W = EXPECTED_STATIC_WEBSITE_WORK[plan_tier] || EXPECTED_STATIC_WEBSITE_WORK.landing;
|
|
15096
|
+
const default_pages = Math.min(W.pages, page_count_limit);
|
|
15097
|
+
return `You are planning a static website. Output STRICT JSON only, no prose, no markdown fences.
|
|
15098
|
+
|
|
15099
|
+
User brief:
|
|
15100
|
+
${user_prompt}
|
|
15101
|
+
|
|
15102
|
+
User-uploaded files (manifest). The generated pages MUST use these where appropriate: embed images via <img src="$public_path">, quote / paraphrase text excerpts where they fit, link to PDFs, structure pages around data when relevant.
|
|
15103
|
+
${JSON.stringify(attachments_manifest, null, 2)}
|
|
15104
|
+
|
|
15105
|
+
Hard rules:
|
|
15106
|
+
- Hosting tier: ${plan_tier}. Maximum ${page_count_limit} pages total. Aim for ${default_pages} pages unless the brief clearly implies more.
|
|
15107
|
+
- Each page has a slug (kebab-case path component, "" for home), title, and an ordered list of sections.
|
|
15108
|
+
- Each page also lists image_specs: image slots needed on that page. For each slot, decide source: 'attachment' (when an uploaded image fits) OR 'generate' (when one must be created).
|
|
15109
|
+
- For 'generate' slots, write a concise visual prompt AND a Pexels-friendly query string.
|
|
15110
|
+
- For 'attachment' slots, set attachment_path to one of the public_paths from the manifest.
|
|
15111
|
+
- style_directive is one paragraph describing the desired visual style (palette, typography, density) used to generate the global stylesheet.
|
|
15112
|
+
|
|
15113
|
+
Schema:
|
|
15114
|
+
{
|
|
15115
|
+
"pages": [
|
|
15116
|
+
{
|
|
15117
|
+
"slug": "string (empty for home)",
|
|
15118
|
+
"title": "string",
|
|
15119
|
+
"sections": [{"kind": "prose|image|quote|data_table|callout", "...": "..."}],
|
|
15120
|
+
"image_specs": [
|
|
15121
|
+
{"slot": "kebab-slug", "source": "generate|attachment", "prompt": "...", "query": "...", "attachment_path": "/uploads/...", "aspect": "16/10|1/1|3/2"}
|
|
15122
|
+
]
|
|
15123
|
+
}
|
|
15124
|
+
],
|
|
15125
|
+
"style_directive": "string"
|
|
15126
|
+
}
|
|
15127
|
+
|
|
15128
|
+
Respond with ONLY the JSON object.`;
|
|
15129
|
+
};
|
|
15130
|
+
|
|
15131
|
+
const _page_html_prompt = (page, style_directive, attachments_manifest) => `Generate a single static HTML page. Output ONLY the HTML, no markdown fences, no commentary.
|
|
15132
|
+
|
|
15133
|
+
Page brief:
|
|
15134
|
+
${JSON.stringify(page, null, 2)}
|
|
15135
|
+
|
|
15136
|
+
Global style directive (matches /style.css that's already written):
|
|
15137
|
+
${style_directive}
|
|
15138
|
+
|
|
15139
|
+
Attachments available (use these public_paths verbatim when referenced):
|
|
15140
|
+
${JSON.stringify(attachments_manifest, null, 2)}
|
|
15141
|
+
|
|
15142
|
+
Hard rules:
|
|
15143
|
+
- DOCTYPE html, lang="en", responsive viewport meta.
|
|
15144
|
+
- Link to /style.css.
|
|
15145
|
+
- Reference images from /img/{slot}.{ext} for 'generate' slots and from their public_path for 'attachment' slots, exactly as listed in image_specs.
|
|
15146
|
+
- Semantic HTML5 (header, main, section, footer). No inline styles unless absolutely necessary.
|
|
15147
|
+
- No external CDNs, no JavaScript.
|
|
15148
|
+
- Title from page.title.
|
|
15149
|
+
|
|
15150
|
+
Respond with ONLY the raw HTML document.`;
|
|
15151
|
+
|
|
15152
|
+
const _css_prompt = (style_directive, plan_tier) => `Generate ONE global stylesheet for a ${plan_tier}-tier static website. Output ONLY the CSS, no markdown fences, no commentary.
|
|
15153
|
+
|
|
15154
|
+
Style directive:
|
|
15155
|
+
${style_directive}
|
|
15156
|
+
|
|
15157
|
+
Hard rules:
|
|
15158
|
+
- Modern CSS only (no preprocessors). Use CSS variables for palette.
|
|
15159
|
+
- Responsive: mobile-first, breakpoints at 640px and 980px.
|
|
15160
|
+
- Typography: choose a serif heading + sans-serif body OR all sans, consistent with the directive.
|
|
15161
|
+
- Include base resets, layout containers, header, footer, basic image styling (max-width 100%, rounded), button styles.
|
|
15162
|
+
- Keep under 6 KB.
|
|
15163
|
+
|
|
15164
|
+
Respond with ONLY the raw CSS text.`;
|
|
15165
|
+
|
|
15166
|
+
const _sitemap_xml = (pages, hostname) => {
|
|
15167
|
+
const base = `https://${hostname}`;
|
|
15168
|
+
const urls = pages
|
|
15169
|
+
.map((p) => {
|
|
15170
|
+
const loc = p.slug ? `${base}/${p.slug}/` : `${base}/`;
|
|
15171
|
+
return ` <url><loc>${loc}</loc></url>`;
|
|
15172
|
+
})
|
|
15173
|
+
.join('\n');
|
|
15174
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
|
|
15175
|
+
};
|
|
15176
|
+
|
|
15177
|
+
const _robots_txt = (hostname) => `User-agent: *\nAllow: /\nSitemap: https://${hostname}/sitemap.xml\n`;
|
|
15178
|
+
|
|
15179
|
+
// Picks an extension for a generated image filename from its content-type.
|
|
15180
|
+
const _image_ext = (content_type) => {
|
|
15181
|
+
if (!content_type) return 'png';
|
|
15182
|
+
if (/webp/i.test(content_type)) return 'webp';
|
|
15183
|
+
if (/jpe?g/i.test(content_type)) return 'jpg';
|
|
15184
|
+
if (/png/i.test(content_type)) return 'png';
|
|
15185
|
+
if (/svg/i.test(content_type)) return 'svg';
|
|
15186
|
+
return 'png';
|
|
15187
|
+
};
|
|
15188
|
+
|
|
15189
|
+
export const generate_site_files = async (req) => {
|
|
15190
|
+
try {
|
|
15191
|
+
const { uid, prompt, attachments, plan_tier, page_count_limit, image_source, account_profile_info, hostname = 'sites.xuda.io' } = req;
|
|
15192
|
+
if (!uid || !prompt) return { code: -1, data: 'uid + prompt required' };
|
|
15193
|
+
if (!plan_tier) return { code: -1, data: 'plan_tier required' };
|
|
15194
|
+
|
|
15195
|
+
// 1. Ingest attachments (copies them into the output file list verbatim)
|
|
15196
|
+
const ingestion = await ingest_attachments(attachments || []);
|
|
15197
|
+
|
|
15198
|
+
// 2. Plan the site
|
|
15199
|
+
const plan_text = await _chat(uid, _site_planning_prompt(prompt, ingestion.manifest, plan_tier, page_count_limit), account_profile_info, { stage: 'static_website_plan', plan_tier });
|
|
15200
|
+
const plan = _safe_json_parse(plan_text);
|
|
15201
|
+
if (!plan || !Array.isArray(plan.pages) || !plan.pages.length) {
|
|
15202
|
+
return { code: -2, data: 'planning JSON malformed', plan_text };
|
|
15203
|
+
}
|
|
15204
|
+
|
|
15205
|
+
// 3. Hard-cap page count
|
|
15206
|
+
plan.pages = plan.pages.slice(0, Math.max(1, Math.min(page_count_limit, plan.pages.length)));
|
|
15207
|
+
|
|
15208
|
+
// 4. Global CSS
|
|
15209
|
+
const css = await _chat(uid, _css_prompt(plan.style_directive || '', plan_tier), account_profile_info, { stage: 'static_website_css', plan_tier });
|
|
15210
|
+
|
|
15211
|
+
// 5. Per-page HTML
|
|
15212
|
+
const html_files = [];
|
|
15213
|
+
for (const page of plan.pages) {
|
|
15214
|
+
const html = await _chat(uid, _page_html_prompt(page, plan.style_directive || '', ingestion.manifest), account_profile_info, { stage: 'static_website_page', plan_tier, slug: page.slug || '' });
|
|
15215
|
+
const rel = page.slug ? `/${page.slug}/index.html` : '/index.html';
|
|
15216
|
+
html_files.push({
|
|
15217
|
+
path: rel,
|
|
15218
|
+
content_b64: Buffer.from(html, 'utf8').toString('base64'),
|
|
15219
|
+
content_type: 'text/html',
|
|
15220
|
+
});
|
|
15221
|
+
}
|
|
15222
|
+
|
|
15223
|
+
// 6. Per-image generation
|
|
15224
|
+
const image_files = [];
|
|
15225
|
+
for (const page of plan.pages) {
|
|
15226
|
+
const specs = Array.isArray(page.image_specs) ? page.image_specs : [];
|
|
15227
|
+
for (const spec of specs) {
|
|
15228
|
+
try {
|
|
15229
|
+
if (spec.source === 'attachment') {
|
|
15230
|
+
// already in ingestion.file_entries — nothing to add
|
|
15231
|
+
continue;
|
|
15232
|
+
}
|
|
15233
|
+
if (image_source === 'pexels') {
|
|
15234
|
+
const [img] = await pexels_search(spec.query || spec.prompt || page.title, 1);
|
|
15235
|
+
if (!img) continue;
|
|
15236
|
+
image_files.push({
|
|
15237
|
+
path: `/img/${spec.slot}.jpg`,
|
|
15238
|
+
content_b64: img.content_b64,
|
|
15239
|
+
content_type: 'image/jpeg',
|
|
15240
|
+
});
|
|
15241
|
+
} else {
|
|
15242
|
+
const b64 = await create_image(
|
|
15243
|
+
uid,
|
|
15244
|
+
spec.prompt || spec.query || page.title,
|
|
15245
|
+
image_source, // 'gpt-image-1' | 'chatgpt-image-latest'
|
|
15246
|
+
'1024x1024',
|
|
15247
|
+
1,
|
|
15248
|
+
1024,
|
|
15249
|
+
1024, // full-resolution output
|
|
15250
|
+
{ kind: 'static_website', slot: spec.slot },
|
|
15251
|
+
account_profile_info,
|
|
15252
|
+
);
|
|
15253
|
+
if (!b64) continue;
|
|
15254
|
+
image_files.push({
|
|
15255
|
+
path: `/img/${spec.slot}.png`,
|
|
15256
|
+
content_b64: b64,
|
|
15257
|
+
content_type: 'image/png',
|
|
15258
|
+
});
|
|
15259
|
+
}
|
|
15260
|
+
} catch (err) {
|
|
15261
|
+
console.error('[generate_site_files] image fail', spec.slot, err.message);
|
|
15262
|
+
}
|
|
15263
|
+
}
|
|
15264
|
+
}
|
|
15265
|
+
|
|
15266
|
+
// 7. SEO files (seo + enterprise tiers only)
|
|
15267
|
+
const seo_files = [];
|
|
15268
|
+
if (plan_tier === 'seo' || plan_tier === 'enterprise') {
|
|
15269
|
+
seo_files.push({
|
|
15270
|
+
path: '/sitemap.xml',
|
|
15271
|
+
content_b64: Buffer.from(_sitemap_xml(plan.pages, hostname), 'utf8').toString('base64'),
|
|
15272
|
+
content_type: 'application/xml',
|
|
15273
|
+
});
|
|
15274
|
+
seo_files.push({
|
|
15275
|
+
path: '/robots.txt',
|
|
15276
|
+
content_b64: Buffer.from(_robots_txt(hostname), 'utf8').toString('base64'),
|
|
15277
|
+
content_type: 'text/plain',
|
|
15278
|
+
});
|
|
15279
|
+
}
|
|
15280
|
+
|
|
15281
|
+
// 8. Compose final flat file list
|
|
15282
|
+
const css_file = {
|
|
15283
|
+
path: '/style.css',
|
|
15284
|
+
content_b64: Buffer.from(css, 'utf8').toString('base64'),
|
|
15285
|
+
content_type: 'text/css',
|
|
15286
|
+
};
|
|
15287
|
+
const files = [css_file, ...html_files, ...image_files, ...seo_files, ...ingestion.file_entries];
|
|
15288
|
+
|
|
15289
|
+
return {
|
|
15290
|
+
code: 1,
|
|
15291
|
+
data: {
|
|
15292
|
+
files,
|
|
15293
|
+
plan,
|
|
15294
|
+
manifest: ingestion.manifest,
|
|
15295
|
+
stats: {
|
|
15296
|
+
page_count: plan.pages.length,
|
|
15297
|
+
image_count: image_files.length,
|
|
15298
|
+
attachment_count: ingestion.file_entries.length,
|
|
15299
|
+
},
|
|
15300
|
+
},
|
|
15301
|
+
};
|
|
15302
|
+
} catch (err) {
|
|
15303
|
+
return { code: -400, data: err.message };
|
|
15304
|
+
}
|
|
15305
|
+
};
|
|
15306
|
+
|
|
15307
|
+
// ── Modify orchestrator ────────────────────────────────────────────────
|
|
15308
|
+
//
|
|
15309
|
+
// Loads current files, asks LLM what to change, writes back only the deltas.
|
|
15310
|
+
// Image source stays pinned to whatever was chosen at creation - modify does
|
|
15311
|
+
// NOT silently upgrade Pexels -> AI (that would surprise free users).
|
|
15312
|
+
|
|
15313
|
+
const _modify_prompt = (user_prompt, current_files_index, attachments_manifest) => `You are editing an existing static website. Output STRICT JSON only, no prose, no markdown fences.
|
|
15314
|
+
|
|
15315
|
+
User request:
|
|
15316
|
+
${user_prompt}
|
|
15317
|
+
|
|
15318
|
+
Current file index (path + size + content-type — NOT full content; ask the user to re-attach if you need to read a file's body):
|
|
15319
|
+
${JSON.stringify(current_files_index, null, 2)}
|
|
15320
|
+
|
|
15321
|
+
New attachments uploaded with this request:
|
|
15322
|
+
${JSON.stringify(attachments_manifest, null, 2)}
|
|
15323
|
+
|
|
15324
|
+
For each change you want to make, emit one entry in "changes":
|
|
15325
|
+
{ "path": "/index.html", "action": "replace", "new_content": "...full new file content..." }
|
|
15326
|
+
{ "path": "/about/index.html", "action": "create", "new_content": "..." }
|
|
15327
|
+
{ "path": "/old-page/index.html", "action": "delete" }
|
|
15328
|
+
|
|
15329
|
+
Image regeneration: emit { "image_change": { "path": "/img/hero.png", "new_prompt": "..." } } and the orchestrator will re-render via the locked image source.
|
|
15330
|
+
|
|
15331
|
+
Hard rules:
|
|
15332
|
+
- Touch only files you actually need to change. Do NOT rewrite the whole site.
|
|
15333
|
+
- new_content for HTML files: full document, DOCTYPE through </html>. Link /style.css unchanged unless explicitly told to restyle.
|
|
15334
|
+
- Do NOT change file paths for unchanged pages.
|
|
15335
|
+
|
|
15336
|
+
Schema:
|
|
15337
|
+
{ "changes": [...], "summary": "one sentence" }
|
|
15338
|
+
|
|
15339
|
+
Respond with ONLY the JSON object.`;
|
|
15340
|
+
|
|
15341
|
+
export const modify_site_files = async (req) => {
|
|
15342
|
+
try {
|
|
15343
|
+
const { uid, current_files, prompt, attachments, plan_tier, image_source, account_profile_info } = req;
|
|
15344
|
+
if (!uid || !prompt) return { code: -1, data: 'uid + prompt required' };
|
|
15345
|
+
if (!Array.isArray(current_files) || !current_files.length) return { code: -1, data: 'current_files required' };
|
|
15346
|
+
|
|
15347
|
+
const ingestion = await ingest_attachments(attachments || []);
|
|
15348
|
+
const index = current_files.map((f) => ({
|
|
15349
|
+
path: f.path,
|
|
15350
|
+
content_type: f.content_type,
|
|
15351
|
+
size_b64: (f.content_b64 || '').length,
|
|
15352
|
+
}));
|
|
15353
|
+
|
|
15354
|
+
const diff_text = await _chat(uid, _modify_prompt(prompt, index, ingestion.manifest), account_profile_info, { stage: 'static_website_modify', plan_tier });
|
|
15355
|
+
const diff = _safe_json_parse(diff_text);
|
|
15356
|
+
if (!diff || !Array.isArray(diff.changes)) {
|
|
15357
|
+
return { code: -2, data: 'modify JSON malformed', diff_text };
|
|
15358
|
+
}
|
|
15359
|
+
|
|
15360
|
+
const changed_paths = [];
|
|
15361
|
+
const updated_files = [...current_files];
|
|
15362
|
+
|
|
15363
|
+
const _find = (p) => updated_files.findIndex((f) => f.path === p);
|
|
15364
|
+
|
|
15365
|
+
for (const ch of diff.changes) {
|
|
15366
|
+
// Re-rendered image
|
|
15367
|
+
if (ch.image_change && ch.image_change.path && ch.image_change.new_prompt) {
|
|
15368
|
+
const ip = ch.image_change.path;
|
|
15369
|
+
try {
|
|
15370
|
+
if (image_source === 'pexels') {
|
|
15371
|
+
const [img] = await pexels_search(ch.image_change.new_prompt, 1);
|
|
15372
|
+
if (img) {
|
|
15373
|
+
const entry = { path: ip, content_b64: img.content_b64, content_type: 'image/jpeg' };
|
|
15374
|
+
const idx = _find(ip);
|
|
15375
|
+
if (idx >= 0) updated_files[idx] = entry;
|
|
15376
|
+
else updated_files.push(entry);
|
|
15377
|
+
changed_paths.push(ip);
|
|
15378
|
+
}
|
|
15379
|
+
} else {
|
|
15380
|
+
const b64 = await create_image(uid, ch.image_change.new_prompt, image_source, '1024x1024', 1, 1024, 1024, { kind: 'static_website_modify' }, account_profile_info);
|
|
15381
|
+
if (b64) {
|
|
15382
|
+
const entry = { path: ip, content_b64: b64, content_type: 'image/png' };
|
|
15383
|
+
const idx = _find(ip);
|
|
15384
|
+
if (idx >= 0) updated_files[idx] = entry;
|
|
15385
|
+
else updated_files.push(entry);
|
|
15386
|
+
changed_paths.push(ip);
|
|
15387
|
+
}
|
|
15388
|
+
}
|
|
15389
|
+
} catch (err) {
|
|
15390
|
+
console.error('[modify_site_files] image regen fail', ip, err.message);
|
|
15391
|
+
}
|
|
15392
|
+
continue;
|
|
15393
|
+
}
|
|
15394
|
+
|
|
15395
|
+
if (!ch.path || !ch.action) continue;
|
|
15396
|
+
const idx = _find(ch.path);
|
|
15397
|
+
|
|
15398
|
+
if (ch.action === 'delete') {
|
|
15399
|
+
if (idx >= 0) {
|
|
15400
|
+
updated_files.splice(idx, 1);
|
|
15401
|
+
changed_paths.push(ch.path);
|
|
15402
|
+
}
|
|
15403
|
+
continue;
|
|
15404
|
+
}
|
|
15405
|
+
|
|
15406
|
+
if ((ch.action === 'replace' || ch.action === 'create') && typeof ch.new_content === 'string') {
|
|
15407
|
+
const ext = (ch.path.match(/\.([a-z0-9]+)$/i) || [, ''])[1].toLowerCase();
|
|
15408
|
+
const content_type = ext === 'html' ? 'text/html' : ext === 'css' ? 'text/css' : ext === 'js' ? 'application/javascript' : ext === 'json' ? 'application/json' : ext === 'xml' ? 'application/xml' : ext === 'txt' ? 'text/plain' : 'text/plain';
|
|
15409
|
+
const entry = {
|
|
15410
|
+
path: ch.path,
|
|
15411
|
+
content_b64: Buffer.from(ch.new_content, 'utf8').toString('base64'),
|
|
15412
|
+
content_type,
|
|
15413
|
+
};
|
|
15414
|
+
if (idx >= 0) updated_files[idx] = entry;
|
|
15415
|
+
else updated_files.push(entry);
|
|
15416
|
+
changed_paths.push(ch.path);
|
|
15417
|
+
}
|
|
15418
|
+
}
|
|
15419
|
+
|
|
15420
|
+
// Any new attachments uploaded this round join the file set verbatim.
|
|
15421
|
+
for (const entry of ingestion.file_entries) {
|
|
15422
|
+
const idx = _find(entry.path);
|
|
15423
|
+
if (idx >= 0) updated_files[idx] = entry;
|
|
15424
|
+
else updated_files.push(entry);
|
|
15425
|
+
changed_paths.push(entry.path);
|
|
15426
|
+
}
|
|
15427
|
+
|
|
15428
|
+
return {
|
|
15429
|
+
code: 1,
|
|
15430
|
+
data: {
|
|
15431
|
+
updated_files,
|
|
15432
|
+
changed_paths,
|
|
15433
|
+
summary: diff.summary || '',
|
|
15434
|
+
},
|
|
15435
|
+
};
|
|
15436
|
+
} catch (err) {
|
|
15437
|
+
return { code: -400, data: err.message };
|
|
15438
|
+
}
|
|
15439
|
+
};
|
package/index_ms.mjs
CHANGED
|
@@ -17,6 +17,30 @@
|
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
export const pick_image_source = async function (...args) {
|
|
21
|
+
return await broker.send_to_queue("pick_image_source", ...args);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const pexels_search = async function (...args) {
|
|
25
|
+
return await broker.send_to_queue("pexels_search", ...args);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const estimate_static_website_cost = async function (...args) {
|
|
29
|
+
return await broker.send_to_queue("estimate_static_website_cost", ...args);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const ingest_attachments = async function (...args) {
|
|
33
|
+
return await broker.send_to_queue("ingest_attachments", ...args);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const generate_site_files = async function (...args) {
|
|
37
|
+
return await broker.send_to_queue("generate_site_files", ...args);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const modify_site_files = async function (...args) {
|
|
41
|
+
return await broker.send_to_queue("modify_site_files", ...args);
|
|
42
|
+
};
|
|
43
|
+
|
|
20
44
|
export const execute_codex_request = async function (...args) {
|
|
21
45
|
return await broker.send_to_queue("execute_codex_request", ...args);
|
|
22
46
|
};
|
|
@@ -193,6 +217,10 @@ export const is_spam_email = async function (...args) {
|
|
|
193
217
|
return await broker.send_to_queue("is_spam_email", ...args);
|
|
194
218
|
};
|
|
195
219
|
|
|
220
|
+
export const marketplace_review_item = async function (...args) {
|
|
221
|
+
return await broker.send_to_queue("marketplace_review_item", ...args);
|
|
222
|
+
};
|
|
223
|
+
|
|
196
224
|
export const is_business_contact = async function (...args) {
|
|
197
225
|
return await broker.send_to_queue("is_business_contact", ...args);
|
|
198
226
|
};
|
|
@@ -253,6 +281,22 @@ export const widget_signup_google = async function (...args) {
|
|
|
253
281
|
return await broker.send_to_queue("widget_signup_google", ...args);
|
|
254
282
|
};
|
|
255
283
|
|
|
284
|
+
export const site_signup_google = async function (...args) {
|
|
285
|
+
return await broker.send_to_queue("site_signup_google", ...args);
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
export const public_profile_ask_friendship = async function (...args) {
|
|
289
|
+
return await broker.send_to_queue("public_profile_ask_friendship", ...args);
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
export const widget_get_avatar = async function (...args) {
|
|
293
|
+
return await broker.send_to_queue("widget_get_avatar", ...args);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
export const site_get_avatar = async function (...args) {
|
|
297
|
+
return await broker.send_to_queue("site_get_avatar", ...args);
|
|
298
|
+
};
|
|
299
|
+
|
|
256
300
|
export const widget_send_message = async function (...args) {
|
|
257
301
|
return await broker.send_to_queue("widget_send_message", ...args);
|
|
258
302
|
};
|
|
@@ -260,7 +304,3 @@ export const widget_send_message = async function (...args) {
|
|
|
260
304
|
export const widget_get_messages = async function (...args) {
|
|
261
305
|
return await broker.send_to_queue("widget_get_messages", ...args);
|
|
262
306
|
};
|
|
263
|
-
|
|
264
|
-
export const marketplace_review_item = async function (...args) {
|
|
265
|
-
return await broker.send_to_queue("marketplace_review_item", ...args);
|
|
266
|
-
};
|
package/index_msa.mjs
CHANGED
|
@@ -17,6 +17,30 @@
|
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
export const pick_image_source = function (...args) {
|
|
21
|
+
broker.send_to_queue_async("pick_image_source", ...args);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const pexels_search = function (...args) {
|
|
25
|
+
broker.send_to_queue_async("pexels_search", ...args);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const estimate_static_website_cost = function (...args) {
|
|
29
|
+
broker.send_to_queue_async("estimate_static_website_cost", ...args);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const ingest_attachments = function (...args) {
|
|
33
|
+
broker.send_to_queue_async("ingest_attachments", ...args);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const generate_site_files = function (...args) {
|
|
37
|
+
broker.send_to_queue_async("generate_site_files", ...args);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const modify_site_files = function (...args) {
|
|
41
|
+
broker.send_to_queue_async("modify_site_files", ...args);
|
|
42
|
+
};
|
|
43
|
+
|
|
20
44
|
export const execute_codex_request = function (...args) {
|
|
21
45
|
broker.send_to_queue_async("execute_codex_request", ...args);
|
|
22
46
|
};
|
|
@@ -193,6 +217,10 @@ export const is_spam_email = function (...args) {
|
|
|
193
217
|
broker.send_to_queue_async("is_spam_email", ...args);
|
|
194
218
|
};
|
|
195
219
|
|
|
220
|
+
export const marketplace_review_item = function (...args) {
|
|
221
|
+
broker.send_to_queue_async("marketplace_review_item", ...args);
|
|
222
|
+
};
|
|
223
|
+
|
|
196
224
|
export const is_business_contact = function (...args) {
|
|
197
225
|
broker.send_to_queue_async("is_business_contact", ...args);
|
|
198
226
|
};
|
|
@@ -253,6 +281,22 @@ export const widget_signup_google = function (...args) {
|
|
|
253
281
|
broker.send_to_queue_async("widget_signup_google", ...args);
|
|
254
282
|
};
|
|
255
283
|
|
|
284
|
+
export const site_signup_google = function (...args) {
|
|
285
|
+
broker.send_to_queue_async("site_signup_google", ...args);
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
export const public_profile_ask_friendship = function (...args) {
|
|
289
|
+
broker.send_to_queue_async("public_profile_ask_friendship", ...args);
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
export const widget_get_avatar = function (...args) {
|
|
293
|
+
broker.send_to_queue_async("widget_get_avatar", ...args);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
export const site_get_avatar = function (...args) {
|
|
297
|
+
broker.send_to_queue_async("site_get_avatar", ...args);
|
|
298
|
+
};
|
|
299
|
+
|
|
256
300
|
export const widget_send_message = function (...args) {
|
|
257
301
|
broker.send_to_queue_async("widget_send_message", ...args);
|
|
258
302
|
};
|