@xuda.io/ai_module 1.1.5613 → 1.1.5615

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.mjs CHANGED
@@ -2085,6 +2085,10 @@ const create_image = async function (uid, prompt, model = 'gpt-image-1-mini', si
2085
2085
  prompt,
2086
2086
  size,
2087
2087
  n,
2088
+ // Output is downscaled to width×height (default 256²) right below, so a
2089
+ // high-quality 1024² render is wasted. 'low' is indistinguishable at
2090
+ // thumbnail size and 2-8x cheaper. Tunable via _conf.chat_image_quality.
2091
+ quality: _conf.chat_image_quality || 'low',
2088
2092
  });
2089
2093
  report_ai_status(model);
2090
2094
  } catch (err) {
@@ -9178,6 +9182,78 @@ export const is_spam_email = async function (uid, email, subject, account_profil
9178
9182
  } catch (error) {}
9179
9183
  };
9180
9184
 
9185
+ // AI screening for a marketplace publish. Receives plain-JSON context from
9186
+ // marketplace_module (zod schemas cannot cross the broker, so the prompt +
9187
+ // structured schema must live here). Returns a structured verdict across the
9188
+ // approval rule dimensions. The caller (marketplace_approval_review) merges
9189
+ // this with deterministic account-standing checks and decides the final stat.
9190
+ export const marketplace_review_item = async function (req) {
9191
+ const { uid, item_type, item = {}, account_context = {}, account_name = '', model = _conf.default_ai_model, account_profile_info } = req || {};
9192
+
9193
+ const check_schema = z.object({
9194
+ flag: z.boolean().describe('true if this concern is present'),
9195
+ severity: z.enum(['none', 'low', 'medium', 'high']).describe('how serious the concern is'),
9196
+ note: z.string().describe('one short sentence of evidence; empty string if no concern'),
9197
+ });
9198
+
9199
+ const response_format = z.object({
9200
+ decision: z.enum(['approve', 'reject', 'manual_review']).describe('approve = safe to publish; reject = clear violation; manual_review = uncertain, needs a human'),
9201
+ confidence: z.number().min(0).max(100).describe('confidence in the decision, 0-100'),
9202
+ checks: z.object({
9203
+ ai_bot_activity: check_schema.describe('automated/bot publishing: high velocity, near-duplicate items, low-effort generated bulk'),
9204
+ commercial_undisclosed: check_schema.describe('undisclosed commercial intent: external paid links, lead-gen, off-platform payment, advertising inconsistent with declared price/category'),
9205
+ harmful_code: check_schema.describe('destructive ops, data exfiltration, crypto-miners, obfuscation, malicious network calls in the submitted code'),
9206
+ suspicious_activity: check_schema.describe('mismatched metadata, hidden payloads, evasion patterns'),
9207
+ malformed_account_name: check_schema.describe('gibberish, homoglyphs, embedded URLs, or impersonation in the account name'),
9208
+ spam_low_quality: check_schema.describe('spam, filler, or low-quality content with no real utility'),
9209
+ impersonation_trademark: check_schema.describe('impersonates a brand/person or abuses a trademark'),
9210
+ prohibited_content: check_schema.describe('adult, illegal, hateful, or otherwise disallowed content'),
9211
+ plagiarism_duplicate: check_schema.describe('appears copied from or duplicates an existing item'),
9212
+ secrets_pii_leak: check_schema.describe('hardcoded API keys, tokens, credentials, or exposed PII in the code'),
9213
+ }),
9214
+ reasons: z.array(z.string()).describe('short user-facing lines explaining the decision (empty if approved cleanly)'),
9215
+ });
9216
+
9217
+ const prompt = `You are the xuda marketplace moderation reviewer. Decide whether the following ${item_type} should be published to the public marketplace.
9218
+
9219
+ Account name: ${account_name}
9220
+ Account context (signals): ${JSON.stringify(account_context)}
9221
+
9222
+ Item:
9223
+ - name: ${item.name || ''}
9224
+ - description: ${item.description || ''}
9225
+ - category: ${item.category || ''}
9226
+ - tags: ${JSON.stringify(item.tags || [])}
9227
+ - price: ${item.price ?? 0}
9228
+ - code/content summary: ${item.code_summary || '(none provided)'}
9229
+
9230
+ Evaluate every check honestly. Set decision to:
9231
+ - "approve" when no medium/high concerns and the item is genuine and useful.
9232
+ - "reject" when there is a clear high-severity violation (harmful code, leaked secrets, prohibited content, obvious impersonation/spam, or clear bot abuse).
9233
+ - "manual_review" when concerns are present but ambiguous, or you are not confident.
9234
+ Keep "reasons" concise, factual, and user-facing (no internal jargon).`;
9235
+
9236
+ const ret = await submit_chat_gpt_prompt({
9237
+ uid,
9238
+ prompt,
9239
+ model,
9240
+ response_format,
9241
+ metadata: { func: 'marketplace_review_item', item_type },
9242
+ account_profile_info,
9243
+ });
9244
+
9245
+ if (ret.code < 0) {
9246
+ return { code: ret.code, data: ret.data };
9247
+ }
9248
+
9249
+ try {
9250
+ const verdict = JSON.parse(ret.data);
9251
+ return { code: 1, data: { ...verdict, model } };
9252
+ } catch (err) {
9253
+ return { code: -1, data: `marketplace_review_item parse error: ${err.message}` };
9254
+ }
9255
+ };
9256
+
9181
9257
  export const is_business_contact = async function (uid, email, name, subject, body, account_profile_info) {
9182
9258
  let prompt = `detect if the email "${email}", subject "${subject}", name "${name}", or body "${body}" is business or personal. Return true if it is a business , false otherwise.
9183
9259
 
@@ -9403,9 +9479,9 @@ async function restoreFaceWithOpenAI(base64Image, ctx = {}) {
9403
9479
  response = await client.images.edit({
9404
9480
  model,
9405
9481
  image: imageFile,
9406
- input_fidelity: 'high',
9407
- quality: 'high',
9408
- size: 'auto',
9482
+ input_fidelity: 'high', // identity preservation — keep high (don't degrade)
9483
+ 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.
9484
+ size: _conf.avatar_image_size || '1024x1024', // was 'auto' (could pick 1536 = ~1.5x output tokens); avatars are square
9409
9485
  prompt:
9410
9486
  '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.',
9411
9487
  });
@@ -14256,7 +14332,7 @@ export const get_widget_embed_snippet = async function (req, job_id, headers) {
14256
14332
  };
14257
14333
 
14258
14334
  const _widget_signup_core = async function (req, job_id, headers, opts) {
14259
- const { profile_id, email, name, widget_origin, google_sub, signup_method } = req;
14335
+ const { profile_id, email, name, widget_origin, google_sub, signup_method, visitor_lang } = req;
14260
14336
  const [owner_uid_part, pid_part] = String(profile_id || '').split('.');
14261
14337
  if (!owner_uid_part || !pid_part) return { code: -404, data: 'invalid_profile' };
14262
14338
 
@@ -14268,19 +14344,19 @@ const _widget_signup_core = async function (req, job_id, headers, opts) {
14268
14344
  if (ap_doc.widget_enabled === false) return { code: -404, data: 'widget_disabled' };
14269
14345
 
14270
14346
  const visitor_uid = await _widget_find_or_create_visitor(email, name, google_sub);
14271
- 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);
14347
+ return await _widget_signup_finalize(visitor_uid, email, name, widget_origin, signup_method, account_profile_info, canonical_owner_uid, canonical_profile_id, ap_doc, job_id, headers, visitor_lang);
14272
14348
  };
14273
14349
 
14274
14350
  export const widget_signup = async function (req, job_id, headers) {
14275
14351
  try {
14276
- let { profile_id, email, name, widget_origin } = req;
14352
+ let { profile_id, email, name, widget_origin, visitor_lang } = req;
14277
14353
  if (typeof email !== 'string' || typeof name !== 'string') return { code: -1, data: 'invalid_input' };
14278
14354
  email = email.toLowerCase().trim();
14279
14355
  name = name.trim();
14280
14356
  if (!email || !name) return { code: -1, data: 'invalid_input' };
14281
14357
  if (!WIDGET_EMAIL_RE.test(email)) return { code: -1, data: 'invalid_email' };
14282
14358
 
14283
- return await _widget_signup_core({ profile_id, email, name, widget_origin, signup_method: 'email' }, job_id, headers);
14359
+ return await _widget_signup_core({ profile_id, email, name, widget_origin, visitor_lang, signup_method: 'email' }, job_id, headers);
14284
14360
  } catch (err) {
14285
14361
  return { code: -1, data: err.message || String(err) };
14286
14362
  }
@@ -14288,7 +14364,7 @@ export const widget_signup = async function (req, job_id, headers) {
14288
14364
 
14289
14365
  export const widget_signup_google = async function (req, job_id, headers) {
14290
14366
  try {
14291
- const { profile_id, id_token, widget_origin } = req;
14367
+ const { profile_id, id_token, widget_origin, visitor_lang } = req;
14292
14368
  if (!id_token || typeof id_token !== 'string') return { code: -1, data: 'missing_id_token' };
14293
14369
 
14294
14370
  let payload;
@@ -14304,13 +14380,13 @@ export const widget_signup_google = async function (req, job_id, headers) {
14304
14380
  const name = String(payload.name || payload.email?.split('@')[0] || 'Friend').trim();
14305
14381
  if (!email) return { code: -1, data: 'no_email_in_token' };
14306
14382
 
14307
- return await _widget_signup_core({ profile_id, email, name, widget_origin, google_sub: payload.sub, signup_method: 'google' }, job_id, headers);
14383
+ return await _widget_signup_core({ profile_id, email, name, widget_origin, visitor_lang, google_sub: payload.sub, signup_method: 'google' }, job_id, headers);
14308
14384
  } catch (err) {
14309
14385
  return { code: -1, data: err.message || String(err) };
14310
14386
  }
14311
14387
  };
14312
14388
 
14313
- 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) {
14389
+ const _widget_signup_finalize = async function (visitor_uid, email, name, widget_origin, signup_method, account_profile_info, canonical_owner_uid, canonical_profile_id, ap_doc, job_id, headers, visitor_lang) {
14314
14390
  try {
14315
14391
  let team_req_doc = await _widget_find_existing_team_req(visitor_uid, canonical_profile_id);
14316
14392
  let assigned_uid;
@@ -14374,12 +14450,20 @@ const _widget_signup_finalize = async function (visitor_uid, email, name, widget
14374
14450
 
14375
14451
  const widget_token = `wds_${crypto.randomUUID().replace(/-/g, '')}`;
14376
14452
  const now = Date.now();
14453
+ // Normalize visitor_lang to a short tag (e.g. "ja-JP" → "ja"). The
14454
+ // auto-reply agent reads this from the session to respond in the
14455
+ // visitor's language. Empty / missing → English fallback at reply time.
14456
+ const visitor_lang_base = String(visitor_lang || '')
14457
+ .toLowerCase()
14458
+ .split('-')[0]
14459
+ .slice(0, 8);
14377
14460
  const session_doc = {
14378
14461
  _id: widget_token,
14379
14462
  docType: 'widget_session',
14380
14463
  stat: 2,
14381
14464
  state,
14382
14465
  visitor_uid,
14466
+ visitor_lang: visitor_lang_base || '',
14383
14467
  team_req_id: team_req_doc._id,
14384
14468
  assigned_uid,
14385
14469
  owner_uid: canonical_owner_uid,
@@ -14398,6 +14482,12 @@ const _widget_signup_finalize = async function (visitor_uid, email, name, widget
14398
14482
  const assigned_avatar = assigned_info_ret?.data?.profile_picture || assigned_info_ret?.data?.profile_avatar || '';
14399
14483
  const greeting = ap_doc?.widget_config?.greeting || WIDGET_DEFAULT_GREETING;
14400
14484
 
14485
+ // Surface the visitor's first name so the chat widget can personalize the
14486
+ // greeting on the page ("Hi {visitor_first_name}, I'm Valentina..."). The
14487
+ // widget reads this from the persisted session and replaces the
14488
+ // {visitor_name} placeholder client-side.
14489
+ const visitor_first_name = _widget_split_name(name).first_name || '';
14490
+
14401
14491
  return {
14402
14492
  code: 1,
14403
14493
  data: {
@@ -14407,6 +14497,7 @@ const _widget_signup_finalize = async function (visitor_uid, email, name, widget
14407
14497
  assigned_avatar,
14408
14498
  greeting,
14409
14499
  conversation_id,
14500
+ visitor_first_name,
14410
14501
  signup_method: signup_method || 'email',
14411
14502
  account_active: signup_method === 'google',
14412
14503
  signin_url: signup_method === 'google' ? `https://${_conf.domain || 'xuda.ai'}/dashboard/login` : undefined,
@@ -14542,3 +14633,557 @@ export const widget_get_messages = async function (req, job_id, headers) {
14542
14633
  return { code: -1, data: err.message || String(err) };
14543
14634
  }
14544
14635
  };
14636
+
14637
+ // ============================================================================
14638
+ // Static-website generation pipeline (helpers + orchestrator)
14639
+ // ============================================================================
14640
+ //
14641
+ // pick_image_source - three-bucket router (Pexels / gpt-image-1 / chatgpt-image-latest)
14642
+ // pexels_search - first consumer of _conf.pexels.api_key
14643
+ // estimate_static_website_cost - credit pre-check for create + modify
14644
+ // ingest_attachments - categorize user-uploaded files into the output tree + manifest
14645
+ // generate_site_files - planning prompt + per-page HTML + per-image source + SEO files
14646
+ // modify_site_files - modify-by-prompt over existing file set
14647
+ //
14648
+ // Image-source bucket order (cheapest -> most expensive): Pexels (free) ->
14649
+ // gpt-image-1 (cheap AI) -> chatgpt-image-latest (premium AI). Decision is
14650
+ // driven by membership/workspace tier and credit balance so a paid user
14651
+ // running low auto-degrades from premium to cheap rather than failing.
14652
+
14653
+ const LOW_CREDIT_THRESHOLD = _conf.static_website?.low_credit_threshold ?? 10;
14654
+ const HIGH_CREDIT_THRESHOLD = _conf.static_website?.high_credit_threshold ?? 100;
14655
+
14656
+ export const pick_image_source = (account_doc, credit_balance) => {
14657
+ const free_mem = (account_doc?.membership_plan || 'free') === 'free';
14658
+ const free_ws = (account_doc?.ai_workspace_plan || 'free_ai_workspace') === 'free_ai_workspace';
14659
+ const credits = Number(credit_balance ?? 0);
14660
+ const paid_tier = !free_mem || !free_ws;
14661
+
14662
+ // Bucket 1: no AI option (fully-free + no credits)
14663
+ if (!paid_tier && credits < LOW_CREDIT_THRESHOLD) return 'pexels';
14664
+ // Bucket 3: premium, only for paid users with a comfortable buffer
14665
+ if (paid_tier && credits >= HIGH_CREDIT_THRESHOLD) return 'chatgpt-image-latest';
14666
+ // Bucket 2: cheaper AI - free users with credits, paid users running low (auto-degrade)
14667
+ return 'gpt-image-1';
14668
+ };
14669
+
14670
+ export const pexels_search = async (query, n = 1) => {
14671
+ const api_key = _conf.pexels?.api_key;
14672
+ if (!api_key) throw new Error('pexels.api_key not configured');
14673
+ const url = `https://api.pexels.com/v1/search?query=${encodeURIComponent(query)}&per_page=${Math.max(1, Math.min(80, Number(n) || 1))}`;
14674
+ const r = await fetch(url, { headers: { Authorization: api_key } });
14675
+ if (!r.ok) throw new Error(`pexels search failed: ${r.status}`);
14676
+ const j = await r.json();
14677
+ const photos = (j.photos || []).slice(0, n);
14678
+ return Promise.all(
14679
+ photos.map(async (p) => {
14680
+ const img = await fetch(p.src.large || p.src.medium || p.src.original);
14681
+ if (!img.ok) throw new Error(`pexels fetch failed: ${img.status}`);
14682
+ const buf = Buffer.from(await img.arrayBuffer());
14683
+ return {
14684
+ content_b64: buf.toString('base64'),
14685
+ content_type: 'image/jpeg',
14686
+ src_url: p.url,
14687
+ photographer: p.photographer,
14688
+ photographer_url: p.photographer_url,
14689
+ };
14690
+ }),
14691
+ );
14692
+ };
14693
+
14694
+ // Expected per-tier work for the credit estimator. Real generated count is
14695
+ // usually a fraction of the hard cap (page_count_limit in PLAN_OBJ).
14696
+ const EXPECTED_STATIC_WEBSITE_WORK = {
14697
+ landing: { pages: 1, imgs_per_page: 2, seo: 0, css: 1 },
14698
+ full: { pages: 8, imgs_per_page: 3, seo: 0, css: 1 },
14699
+ seo: { pages: 25, imgs_per_page: 3, seo: 2, css: 1 },
14700
+ enterprise: { pages: 100, imgs_per_page: 3, seo: 2, css: 1 },
14701
+ };
14702
+
14703
+ const _image_price = (image_source) => {
14704
+ if (image_source === 'pexels') return 0;
14705
+ const m = _conf.ai_models?.[image_source];
14706
+ return m?.price ?? Math.max(m?.input ?? 0, m?.output ?? 0) ?? 5;
14707
+ };
14708
+
14709
+ const _text_price = () => {
14710
+ const k = _conf.default_ai_model || 'gpt-5.4-mini';
14711
+ const m = _conf.ai_models?.[k];
14712
+ return m?.price ?? 1;
14713
+ };
14714
+
14715
+ export const estimate_static_website_cost = (req) => {
14716
+ const plan_tier = req?.plan_tier;
14717
+ const image_source = req?.image_source || 'gpt-image-1';
14718
+ const mode = req?.mode || 'create';
14719
+ const current_page_count = Number(req?.current_page_count || 0);
14720
+
14721
+ const W = EXPECTED_STATIC_WEBSITE_WORK[plan_tier] || EXPECTED_STATIC_WEBSITE_WORK.landing;
14722
+ const pages =
14723
+ mode === 'modify'
14724
+ ? Math.max(1, Math.ceil(current_page_count * 0.3)) // modify touches ~30% by default
14725
+ : W.pages;
14726
+ const text_calls = 1 /* site plan */ + W.css + pages /* per-page HTML */ + W.seo;
14727
+ const image_calls = pages * W.imgs_per_page;
14728
+
14729
+ const text_unit = _text_price();
14730
+ const image_unit = _image_price(image_source);
14731
+ const credits_needed = text_calls * text_unit + image_calls * image_unit;
14732
+
14733
+ return {
14734
+ credits_needed,
14735
+ breakdown: { text_calls, text_unit, image_calls, image_unit, pages, mode },
14736
+ image_source,
14737
+ };
14738
+ };
14739
+
14740
+ // ── Attachment ingestion ────────────────────────────────────────────────
14741
+ //
14742
+ // Shape: [{ filename, mime_type, drive_path? | content_b64? | url? }]
14743
+ // At least one source field must be present. Files land in the deployed site
14744
+ // at a stable /uploads/{img,docs,text,data,files}/{slug} path so the LLM can
14745
+ // reference them by URL in generated HTML.
14746
+
14747
+ const _categorize_attachment = (att) => {
14748
+ const mt = (att?.mime_type || '').toLowerCase();
14749
+ if (mt.startsWith('image/')) return 'image';
14750
+ if (mt === 'application/pdf') return 'pdf';
14751
+ if (mt === 'text/csv' || mt === 'application/vnd.ms-excel') return 'data';
14752
+ if (mt === 'application/json' || mt === 'text/markdown' || mt.startsWith('text/')) return 'text';
14753
+ return 'binary';
14754
+ };
14755
+
14756
+ const _load_attachment_bytes = async (att) => {
14757
+ if (att.content_b64) return Buffer.from(att.content_b64, 'base64');
14758
+ if (att.drive_path) {
14759
+ const abs = path.join(_conf.studio_drive_path, att.drive_path);
14760
+ return await readFile(abs);
14761
+ }
14762
+ if (att.url) {
14763
+ const r = await fetch(att.url);
14764
+ if (!r.ok) throw new Error(`attachment fetch ${att.url}: ${r.status}`);
14765
+ return Buffer.from(await r.arrayBuffer());
14766
+ }
14767
+ throw new Error(`attachment ${att.filename} has no source (drive_path | content_b64 | url)`);
14768
+ };
14769
+
14770
+ export const ingest_attachments = async (attachments = []) => {
14771
+ const out = { manifest: [], file_entries: [], text_corpus: [] };
14772
+ for (const att of attachments) {
14773
+ if (!att || !att.filename) continue;
14774
+ const kind = _categorize_attachment(att);
14775
+ let bytes;
14776
+ try {
14777
+ bytes = await _load_attachment_bytes(att);
14778
+ } catch (err) {
14779
+ console.error('[ingest_attachments] skip', att.filename, err.message);
14780
+ continue;
14781
+ }
14782
+ const slug = String(att.filename)
14783
+ .replace(/[^a-z0-9.-]/gi, '_')
14784
+ .toLowerCase();
14785
+ let public_path;
14786
+ if (kind === 'image') public_path = `/uploads/img/${slug}`;
14787
+ else if (kind === 'pdf') public_path = `/uploads/docs/${slug}`;
14788
+ else if (kind === 'text') public_path = `/uploads/text/${slug}`;
14789
+ else if (kind === 'data') public_path = `/uploads/data/${slug}`;
14790
+ else public_path = `/uploads/files/${slug}`;
14791
+
14792
+ out.file_entries.push({
14793
+ path: public_path,
14794
+ content_b64: bytes.toString('base64'),
14795
+ content_type: att.mime_type || 'application/octet-stream',
14796
+ });
14797
+
14798
+ const manifest_entry = { filename: att.filename, kind, public_path };
14799
+ if (kind === 'text' || kind === 'data') {
14800
+ const text = bytes.toString('utf8').slice(0, 8000);
14801
+ manifest_entry.excerpt = text;
14802
+ out.text_corpus.push({ filename: att.filename, text });
14803
+ }
14804
+ if (kind === 'image') {
14805
+ manifest_entry.usage = `Embed via <img src="${public_path}" alt="..."> on relevant pages.`;
14806
+ }
14807
+ out.manifest.push(manifest_entry);
14808
+ }
14809
+ return out;
14810
+ };
14811
+
14812
+ // ── Generation orchestrator ─────────────────────────────────────────────
14813
+ //
14814
+ // generate_site_files runs the prompt -> plan -> pages -> images -> SEO chain.
14815
+ // Returns a flat [{ path, content_b64, content_type }] list ready for
14816
+ // domains_module.deploy_site.
14817
+
14818
+ const _safe_json_parse = (s) => {
14819
+ if (!s || typeof s !== 'string') return null;
14820
+ // The LLM sometimes wraps JSON in ```json fences despite instructions.
14821
+ const cleaned = s
14822
+ .trim()
14823
+ .replace(/^```(?:json)?\s*/i, '')
14824
+ .replace(/\s*```\s*$/i, '');
14825
+ try {
14826
+ return JSON.parse(cleaned);
14827
+ } catch (_) {
14828
+ return null;
14829
+ }
14830
+ };
14831
+
14832
+ const _chat = async (uid, prompt, account_profile_info, metadata) => {
14833
+ const ret = await submit_chat_gpt_prompt({
14834
+ uid,
14835
+ prompt,
14836
+ model: _conf.default_ai_model,
14837
+ metadata: metadata || {},
14838
+ account_profile_info,
14839
+ });
14840
+ if (ret.code < 0) throw new Error(`LLM call failed: ${ret.data}`);
14841
+ return ret.data || '';
14842
+ };
14843
+
14844
+ const _site_planning_prompt = (user_prompt, attachments_manifest, plan_tier, page_count_limit) => {
14845
+ const W = EXPECTED_STATIC_WEBSITE_WORK[plan_tier] || EXPECTED_STATIC_WEBSITE_WORK.landing;
14846
+ const default_pages = Math.min(W.pages, page_count_limit);
14847
+ return `You are planning a static website. Output STRICT JSON only, no prose, no markdown fences.
14848
+
14849
+ User brief:
14850
+ ${user_prompt}
14851
+
14852
+ User-uploaded files (manifest). The generated pages MUST use these where appropriate: embed images via <img src="$public_path">, quote / paraphrase text excerpts where they fit, link to PDFs, structure pages around data when relevant.
14853
+ ${JSON.stringify(attachments_manifest, null, 2)}
14854
+
14855
+ Hard rules:
14856
+ - Hosting tier: ${plan_tier}. Maximum ${page_count_limit} pages total. Aim for ${default_pages} pages unless the brief clearly implies more.
14857
+ - Each page has a slug (kebab-case path component, "" for home), title, and an ordered list of sections.
14858
+ - Each page also lists image_specs: image slots needed on that page. For each slot, decide source: 'attachment' (when an uploaded image fits) OR 'generate' (when one must be created).
14859
+ - For 'generate' slots, write a concise visual prompt AND a Pexels-friendly query string.
14860
+ - For 'attachment' slots, set attachment_path to one of the public_paths from the manifest.
14861
+ - style_directive is one paragraph describing the desired visual style (palette, typography, density) used to generate the global stylesheet.
14862
+
14863
+ Schema:
14864
+ {
14865
+ "pages": [
14866
+ {
14867
+ "slug": "string (empty for home)",
14868
+ "title": "string",
14869
+ "sections": [{"kind": "prose|image|quote|data_table|callout", "...": "..."}],
14870
+ "image_specs": [
14871
+ {"slot": "kebab-slug", "source": "generate|attachment", "prompt": "...", "query": "...", "attachment_path": "/uploads/...", "aspect": "16/10|1/1|3/2"}
14872
+ ]
14873
+ }
14874
+ ],
14875
+ "style_directive": "string"
14876
+ }
14877
+
14878
+ Respond with ONLY the JSON object.`;
14879
+ };
14880
+
14881
+ const _page_html_prompt = (page, style_directive, attachments_manifest) => `Generate a single static HTML page. Output ONLY the HTML, no markdown fences, no commentary.
14882
+
14883
+ Page brief:
14884
+ ${JSON.stringify(page, null, 2)}
14885
+
14886
+ Global style directive (matches /style.css that's already written):
14887
+ ${style_directive}
14888
+
14889
+ Attachments available (use these public_paths verbatim when referenced):
14890
+ ${JSON.stringify(attachments_manifest, null, 2)}
14891
+
14892
+ Hard rules:
14893
+ - DOCTYPE html, lang="en", responsive viewport meta.
14894
+ - Link to /style.css.
14895
+ - Reference images from /img/{slot}.{ext} for 'generate' slots and from their public_path for 'attachment' slots, exactly as listed in image_specs.
14896
+ - Semantic HTML5 (header, main, section, footer). No inline styles unless absolutely necessary.
14897
+ - No external CDNs, no JavaScript.
14898
+ - Title from page.title.
14899
+
14900
+ Respond with ONLY the raw HTML document.`;
14901
+
14902
+ const _css_prompt = (style_directive, plan_tier) => `Generate ONE global stylesheet for a ${plan_tier}-tier static website. Output ONLY the CSS, no markdown fences, no commentary.
14903
+
14904
+ Style directive:
14905
+ ${style_directive}
14906
+
14907
+ Hard rules:
14908
+ - Modern CSS only (no preprocessors). Use CSS variables for palette.
14909
+ - Responsive: mobile-first, breakpoints at 640px and 980px.
14910
+ - Typography: choose a serif heading + sans-serif body OR all sans, consistent with the directive.
14911
+ - Include base resets, layout containers, header, footer, basic image styling (max-width 100%, rounded), button styles.
14912
+ - Keep under 6 KB.
14913
+
14914
+ Respond with ONLY the raw CSS text.`;
14915
+
14916
+ const _sitemap_xml = (pages, hostname) => {
14917
+ const base = `https://${hostname}`;
14918
+ const urls = pages
14919
+ .map((p) => {
14920
+ const loc = p.slug ? `${base}/${p.slug}/` : `${base}/`;
14921
+ return ` <url><loc>${loc}</loc></url>`;
14922
+ })
14923
+ .join('\n');
14924
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
14925
+ };
14926
+
14927
+ const _robots_txt = (hostname) => `User-agent: *\nAllow: /\nSitemap: https://${hostname}/sitemap.xml\n`;
14928
+
14929
+ // Picks an extension for a generated image filename from its content-type.
14930
+ const _image_ext = (content_type) => {
14931
+ if (!content_type) return 'png';
14932
+ if (/webp/i.test(content_type)) return 'webp';
14933
+ if (/jpe?g/i.test(content_type)) return 'jpg';
14934
+ if (/png/i.test(content_type)) return 'png';
14935
+ if (/svg/i.test(content_type)) return 'svg';
14936
+ return 'png';
14937
+ };
14938
+
14939
+ export const generate_site_files = async (req) => {
14940
+ try {
14941
+ const { uid, prompt, attachments, plan_tier, page_count_limit, image_source, account_profile_info, hostname = 'sites.xuda.io' } = req;
14942
+ if (!uid || !prompt) return { code: -1, data: 'uid + prompt required' };
14943
+ if (!plan_tier) return { code: -1, data: 'plan_tier required' };
14944
+
14945
+ // 1. Ingest attachments (copies them into the output file list verbatim)
14946
+ const ingestion = await ingest_attachments(attachments || []);
14947
+
14948
+ // 2. Plan the site
14949
+ const plan_text = await _chat(uid, _site_planning_prompt(prompt, ingestion.manifest, plan_tier, page_count_limit), account_profile_info, { stage: 'static_website_plan', plan_tier });
14950
+ const plan = _safe_json_parse(plan_text);
14951
+ if (!plan || !Array.isArray(plan.pages) || !plan.pages.length) {
14952
+ return { code: -2, data: 'planning JSON malformed', plan_text };
14953
+ }
14954
+
14955
+ // 3. Hard-cap page count
14956
+ plan.pages = plan.pages.slice(0, Math.max(1, Math.min(page_count_limit, plan.pages.length)));
14957
+
14958
+ // 4. Global CSS
14959
+ const css = await _chat(uid, _css_prompt(plan.style_directive || '', plan_tier), account_profile_info, { stage: 'static_website_css', plan_tier });
14960
+
14961
+ // 5. Per-page HTML
14962
+ const html_files = [];
14963
+ for (const page of plan.pages) {
14964
+ const html = await _chat(uid, _page_html_prompt(page, plan.style_directive || '', ingestion.manifest), account_profile_info, { stage: 'static_website_page', plan_tier, slug: page.slug || '' });
14965
+ const rel = page.slug ? `/${page.slug}/index.html` : '/index.html';
14966
+ html_files.push({
14967
+ path: rel,
14968
+ content_b64: Buffer.from(html, 'utf8').toString('base64'),
14969
+ content_type: 'text/html',
14970
+ });
14971
+ }
14972
+
14973
+ // 6. Per-image generation
14974
+ const image_files = [];
14975
+ for (const page of plan.pages) {
14976
+ const specs = Array.isArray(page.image_specs) ? page.image_specs : [];
14977
+ for (const spec of specs) {
14978
+ try {
14979
+ if (spec.source === 'attachment') {
14980
+ // already in ingestion.file_entries — nothing to add
14981
+ continue;
14982
+ }
14983
+ if (image_source === 'pexels') {
14984
+ const [img] = await pexels_search(spec.query || spec.prompt || page.title, 1);
14985
+ if (!img) continue;
14986
+ image_files.push({
14987
+ path: `/img/${spec.slot}.jpg`,
14988
+ content_b64: img.content_b64,
14989
+ content_type: 'image/jpeg',
14990
+ });
14991
+ } else {
14992
+ const b64 = await create_image(
14993
+ uid,
14994
+ spec.prompt || spec.query || page.title,
14995
+ image_source, // 'gpt-image-1' | 'chatgpt-image-latest'
14996
+ '1024x1024',
14997
+ 1,
14998
+ 1024,
14999
+ 1024, // full-resolution output
15000
+ { kind: 'static_website', slot: spec.slot },
15001
+ account_profile_info,
15002
+ );
15003
+ if (!b64) continue;
15004
+ image_files.push({
15005
+ path: `/img/${spec.slot}.png`,
15006
+ content_b64: b64,
15007
+ content_type: 'image/png',
15008
+ });
15009
+ }
15010
+ } catch (err) {
15011
+ console.error('[generate_site_files] image fail', spec.slot, err.message);
15012
+ }
15013
+ }
15014
+ }
15015
+
15016
+ // 7. SEO files (seo + enterprise tiers only)
15017
+ const seo_files = [];
15018
+ if (plan_tier === 'seo' || plan_tier === 'enterprise') {
15019
+ seo_files.push({
15020
+ path: '/sitemap.xml',
15021
+ content_b64: Buffer.from(_sitemap_xml(plan.pages, hostname), 'utf8').toString('base64'),
15022
+ content_type: 'application/xml',
15023
+ });
15024
+ seo_files.push({
15025
+ path: '/robots.txt',
15026
+ content_b64: Buffer.from(_robots_txt(hostname), 'utf8').toString('base64'),
15027
+ content_type: 'text/plain',
15028
+ });
15029
+ }
15030
+
15031
+ // 8. Compose final flat file list
15032
+ const css_file = {
15033
+ path: '/style.css',
15034
+ content_b64: Buffer.from(css, 'utf8').toString('base64'),
15035
+ content_type: 'text/css',
15036
+ };
15037
+ const files = [css_file, ...html_files, ...image_files, ...seo_files, ...ingestion.file_entries];
15038
+
15039
+ return {
15040
+ code: 1,
15041
+ data: {
15042
+ files,
15043
+ plan,
15044
+ manifest: ingestion.manifest,
15045
+ stats: {
15046
+ page_count: plan.pages.length,
15047
+ image_count: image_files.length,
15048
+ attachment_count: ingestion.file_entries.length,
15049
+ },
15050
+ },
15051
+ };
15052
+ } catch (err) {
15053
+ return { code: -400, data: err.message };
15054
+ }
15055
+ };
15056
+
15057
+ // ── Modify orchestrator ────────────────────────────────────────────────
15058
+ //
15059
+ // Loads current files, asks LLM what to change, writes back only the deltas.
15060
+ // Image source stays pinned to whatever was chosen at creation - modify does
15061
+ // NOT silently upgrade Pexels -> AI (that would surprise free users).
15062
+
15063
+ const _modify_prompt = (user_prompt, current_files_index, attachments_manifest) => `You are editing an existing static website. Output STRICT JSON only, no prose, no markdown fences.
15064
+
15065
+ User request:
15066
+ ${user_prompt}
15067
+
15068
+ Current file index (path + size + content-type — NOT full content; ask the user to re-attach if you need to read a file's body):
15069
+ ${JSON.stringify(current_files_index, null, 2)}
15070
+
15071
+ New attachments uploaded with this request:
15072
+ ${JSON.stringify(attachments_manifest, null, 2)}
15073
+
15074
+ For each change you want to make, emit one entry in "changes":
15075
+ { "path": "/index.html", "action": "replace", "new_content": "...full new file content..." }
15076
+ { "path": "/about/index.html", "action": "create", "new_content": "..." }
15077
+ { "path": "/old-page/index.html", "action": "delete" }
15078
+
15079
+ Image regeneration: emit { "image_change": { "path": "/img/hero.png", "new_prompt": "..." } } and the orchestrator will re-render via the locked image source.
15080
+
15081
+ Hard rules:
15082
+ - Touch only files you actually need to change. Do NOT rewrite the whole site.
15083
+ - new_content for HTML files: full document, DOCTYPE through </html>. Link /style.css unchanged unless explicitly told to restyle.
15084
+ - Do NOT change file paths for unchanged pages.
15085
+
15086
+ Schema:
15087
+ { "changes": [...], "summary": "one sentence" }
15088
+
15089
+ Respond with ONLY the JSON object.`;
15090
+
15091
+ export const modify_site_files = async (req) => {
15092
+ try {
15093
+ const { uid, current_files, prompt, attachments, plan_tier, image_source, account_profile_info } = req;
15094
+ if (!uid || !prompt) return { code: -1, data: 'uid + prompt required' };
15095
+ if (!Array.isArray(current_files) || !current_files.length) return { code: -1, data: 'current_files required' };
15096
+
15097
+ const ingestion = await ingest_attachments(attachments || []);
15098
+ const index = current_files.map((f) => ({
15099
+ path: f.path,
15100
+ content_type: f.content_type,
15101
+ size_b64: (f.content_b64 || '').length,
15102
+ }));
15103
+
15104
+ const diff_text = await _chat(uid, _modify_prompt(prompt, index, ingestion.manifest), account_profile_info, { stage: 'static_website_modify', plan_tier });
15105
+ const diff = _safe_json_parse(diff_text);
15106
+ if (!diff || !Array.isArray(diff.changes)) {
15107
+ return { code: -2, data: 'modify JSON malformed', diff_text };
15108
+ }
15109
+
15110
+ const changed_paths = [];
15111
+ const updated_files = [...current_files];
15112
+
15113
+ const _find = (p) => updated_files.findIndex((f) => f.path === p);
15114
+
15115
+ for (const ch of diff.changes) {
15116
+ // Re-rendered image
15117
+ if (ch.image_change && ch.image_change.path && ch.image_change.new_prompt) {
15118
+ const ip = ch.image_change.path;
15119
+ try {
15120
+ if (image_source === 'pexels') {
15121
+ const [img] = await pexels_search(ch.image_change.new_prompt, 1);
15122
+ if (img) {
15123
+ const entry = { path: ip, content_b64: img.content_b64, content_type: 'image/jpeg' };
15124
+ const idx = _find(ip);
15125
+ if (idx >= 0) updated_files[idx] = entry;
15126
+ else updated_files.push(entry);
15127
+ changed_paths.push(ip);
15128
+ }
15129
+ } else {
15130
+ const b64 = await create_image(uid, ch.image_change.new_prompt, image_source, '1024x1024', 1, 1024, 1024, { kind: 'static_website_modify' }, account_profile_info);
15131
+ if (b64) {
15132
+ const entry = { path: ip, content_b64: b64, content_type: 'image/png' };
15133
+ const idx = _find(ip);
15134
+ if (idx >= 0) updated_files[idx] = entry;
15135
+ else updated_files.push(entry);
15136
+ changed_paths.push(ip);
15137
+ }
15138
+ }
15139
+ } catch (err) {
15140
+ console.error('[modify_site_files] image regen fail', ip, err.message);
15141
+ }
15142
+ continue;
15143
+ }
15144
+
15145
+ if (!ch.path || !ch.action) continue;
15146
+ const idx = _find(ch.path);
15147
+
15148
+ if (ch.action === 'delete') {
15149
+ if (idx >= 0) {
15150
+ updated_files.splice(idx, 1);
15151
+ changed_paths.push(ch.path);
15152
+ }
15153
+ continue;
15154
+ }
15155
+
15156
+ if ((ch.action === 'replace' || ch.action === 'create') && typeof ch.new_content === 'string') {
15157
+ const ext = (ch.path.match(/\.([a-z0-9]+)$/i) || [, ''])[1].toLowerCase();
15158
+ const content_type = ext === 'html' ? 'text/html' : ext === 'css' ? 'text/css' : ext === 'js' ? 'application/javascript' : ext === 'json' ? 'application/json' : ext === 'xml' ? 'application/xml' : ext === 'txt' ? 'text/plain' : 'text/plain';
15159
+ const entry = {
15160
+ path: ch.path,
15161
+ content_b64: Buffer.from(ch.new_content, 'utf8').toString('base64'),
15162
+ content_type,
15163
+ };
15164
+ if (idx >= 0) updated_files[idx] = entry;
15165
+ else updated_files.push(entry);
15166
+ changed_paths.push(ch.path);
15167
+ }
15168
+ }
15169
+
15170
+ // Any new attachments uploaded this round join the file set verbatim.
15171
+ for (const entry of ingestion.file_entries) {
15172
+ const idx = _find(entry.path);
15173
+ if (idx >= 0) updated_files[idx] = entry;
15174
+ else updated_files.push(entry);
15175
+ changed_paths.push(entry.path);
15176
+ }
15177
+
15178
+ return {
15179
+ code: 1,
15180
+ data: {
15181
+ updated_files,
15182
+ changed_paths,
15183
+ summary: diff.summary || '',
15184
+ },
15185
+ };
15186
+ } catch (err) {
15187
+ return { code: -400, data: err.message };
15188
+ }
15189
+ };
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
  };
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/ai_module",
3
- "version": "1.1.5613",
3
+ "version": "1.1.5615",
4
4
  "description": "Xuda AI Module",
5
5
  "main": "index.mjs",
6
6
  "type": "module",