syntaxmatrix 2.5.6__py3-none-any.whl → 2.6.2__py3-none-any.whl

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.
Files changed (41) hide show
  1. syntaxmatrix/agentic/agents.py +1220 -169
  2. syntaxmatrix/agentic/agents_orchestrer.py +326 -0
  3. syntaxmatrix/agentic/code_tools_registry.py +27 -32
  4. syntaxmatrix/commentary.py +16 -16
  5. syntaxmatrix/core.py +185 -81
  6. syntaxmatrix/db.py +460 -4
  7. syntaxmatrix/{display.py → display_html.py} +2 -6
  8. syntaxmatrix/gpt_models_latest.py +1 -1
  9. syntaxmatrix/media/__init__.py +0 -0
  10. syntaxmatrix/media/media_pixabay.py +277 -0
  11. syntaxmatrix/models.py +1 -1
  12. syntaxmatrix/page_builder_defaults.py +183 -0
  13. syntaxmatrix/page_builder_generation.py +1122 -0
  14. syntaxmatrix/page_layout_contract.py +644 -0
  15. syntaxmatrix/page_patch_publish.py +1471 -0
  16. syntaxmatrix/preface.py +142 -21
  17. syntaxmatrix/profiles.py +28 -10
  18. syntaxmatrix/routes.py +1740 -453
  19. syntaxmatrix/selftest_page_templates.py +360 -0
  20. syntaxmatrix/settings/client_items.py +28 -0
  21. syntaxmatrix/settings/model_map.py +1022 -207
  22. syntaxmatrix/settings/prompts.py +328 -130
  23. syntaxmatrix/static/assets/hero-default.svg +22 -0
  24. syntaxmatrix/static/icons/bot-icon.png +0 -0
  25. syntaxmatrix/static/icons/favicon.png +0 -0
  26. syntaxmatrix/static/icons/logo.png +0 -0
  27. syntaxmatrix/static/icons/logo3.png +0 -0
  28. syntaxmatrix/templates/admin_branding.html +104 -0
  29. syntaxmatrix/templates/admin_features.html +63 -0
  30. syntaxmatrix/templates/admin_secretes.html +108 -0
  31. syntaxmatrix/templates/dashboard.html +296 -133
  32. syntaxmatrix/templates/dataset_resize.html +535 -0
  33. syntaxmatrix/templates/edit_page.html +2535 -0
  34. syntaxmatrix/utils.py +2431 -2383
  35. {syntaxmatrix-2.5.6.dist-info → syntaxmatrix-2.6.2.dist-info}/METADATA +6 -2
  36. {syntaxmatrix-2.5.6.dist-info → syntaxmatrix-2.6.2.dist-info}/RECORD +39 -24
  37. syntaxmatrix/generate_page.py +0 -644
  38. syntaxmatrix/static/icons/hero_bg.jpg +0 -0
  39. {syntaxmatrix-2.5.6.dist-info → syntaxmatrix-2.6.2.dist-info}/WHEEL +0 -0
  40. {syntaxmatrix-2.5.6.dist-info → syntaxmatrix-2.6.2.dist-info}/licenses/LICENSE.txt +0 -0
  41. {syntaxmatrix-2.5.6.dist-info → syntaxmatrix-2.6.2.dist-info}/top_level.txt +0 -0
syntaxmatrix/routes.py CHANGED
@@ -1,7 +1,7 @@
1
- import os, zipfile, time, uuid, werkzeug, queue, html, ast, re
2
- import threading, textwrap, json, pandas as pd
3
- import contextlib
4
-
1
+ from math import floor
2
+ import os, time, uuid, queue, html, re
3
+ import json, pandas as pd
4
+ import contextlib, werkzeug
5
5
  import io as _std_io
6
6
 
7
7
  from io import BytesIO
@@ -9,18 +9,15 @@ from scipy import io
9
9
  from flask import Blueprint, Response, request, send_file, session
10
10
  from flask import render_template, render_template_string, url_for, redirect, g
11
11
  from flask import flash, jsonify, send_from_directory, get_flashed_messages, stream_with_context
12
-
12
+ from syntaxmatrix.page_patch_publish import patch_page_publish, ensure_sections_exist
13
13
  from flask_login import current_user
14
-
14
+ from syntaxmatrix.page_layout_contract import normalise_layout, validate_layout, validate_compiled_html
15
15
  from PyPDF2 import PdfReader
16
16
  from markupsafe import Markup
17
17
  from urllib.parse import quote
18
18
  from datetime import datetime
19
- from prompt_toolkit import HTML
20
19
  from PyPDF2.errors import EmptyFileError
21
- import numpy as np
22
- from .auth import register_user, authenticate, login_required, admin_required, superadmin_required
23
-
20
+ import numpy as np
24
21
  from syntaxmatrix.themes import DEFAULT_THEMES
25
22
  from syntaxmatrix import db
26
23
  from syntaxmatrix.vector_db import add_pdf_chunk
@@ -32,15 +29,32 @@ from syntaxmatrix.history_store import SQLHistoryStore, PersistentHistoryStore
32
29
  from syntaxmatrix.kernel_manager import SyntaxMatrixKernelManager, execute_code_in_kernel
33
30
  from syntaxmatrix.vector_db import *
34
31
  from syntaxmatrix.settings.string_navbar import string_navbar_items
35
- from syntaxmatrix.settings.model_map import GPT_MODELS_LATEST, PROVIDERS_MODELS, MODEL_DESCRIPTIONS, PURPOSE_TAGS, EMBEDDING_MODELS
36
32
  from syntaxmatrix.project_root import detect_project_root
37
- from syntaxmatrix import generate_page as _genpage
33
+ from syntaxmatrix.page_builder_defaults import make_default_layout, layout_to_html
38
34
  from syntaxmatrix import auth as _auth
39
- from .auth import register_user, authenticate, login_required, admin_required, superadmin_required, update_password
40
-
35
+ from syntaxmatrix.auth import register_user, authenticate, login_required, admin_required, superadmin_required, update_password
41
36
  from syntaxmatrix import profiles as _prof
42
37
  from syntaxmatrix.gpt_models_latest import set_args, extract_output_text as _out
43
- from syntaxmatrix.agentic.agents import classify_ml_job_agent, refine_question_agent, text_formatter_agent
38
+ from syntaxmatrix.agentic.agent_tools import ToolRunner
39
+ from syntaxmatrix.settings.model_map import(
40
+ GPT_MODELS_LATEST,
41
+ PROVIDERS_MODELS,
42
+ MODEL_DESCRIPTIONS,
43
+ PURPOSE_TAGS,
44
+ EMBEDDING_MODELS
45
+ )
46
+ from syntaxmatrix.agentic.agents import (
47
+ classify_ml_job_agent, context_compatibility,
48
+ agentic_generate_page,
49
+ )
50
+
51
+ from syntaxmatrix.page_builder_generation import (
52
+ build_layout_for_page,
53
+ fill_layout_images_from_pixabay,
54
+ compile_layout_to_html,
55
+ patch_page_from_layout,
56
+ patch_section_titles_and_intros,
57
+ )
44
58
 
45
59
  try:
46
60
  from pygments import highlight as _hl
@@ -50,21 +64,11 @@ try:
50
64
  except Exception:
51
65
  _HAVE_PYGMENTS = False
52
66
 
53
- # from syntaxmatrix.utils import *
54
- from syntaxmatrix.utils import (
55
- auto_inject_template, drop_bad_classification_metrics, ensure_accuracy_block,
56
- ensure_image_output, ensure_output, fix_plain_prints, fix_print_html, patch_fix_sentinel_plot_calls,
57
- patch_pairplot, fix_to_datetime_errors, harden_ai_code, patch_ensure_seaborn_import, get_plotting_imports,
58
- patch_fix_seaborn_palette_calls, patch_quiet_specific_warnings, fix_seaborn_barplot_nameerror, fix_seaborn_boxplot_nameerror, ensure_matplotlib_title, patch_plot_code, patch_prefix_seaborn_calls, fix_scatter_and_summary, inject_auto_preprocessing, fix_importance_groupby, patch_pie_chart, patch_rmse_calls, clean_llm_code
59
- )
60
-
61
- from syntaxmatrix.agentic.agent_tools import ToolRunner
62
- from syntaxmatrix.agentic.code_tools_registry import EARLY_SANITIZERS, SYNTAX_AND_REPAIR
63
-
64
67
  _CLIENT_DIR = detect_project_root()
65
68
  _stream_q = queue.Queue()
66
69
  _stream_cancelled = {}
67
70
  _last_result_html = {} # { session_id: html_doc }
71
+ _last_resized_csv = {} # { resize_id: bytes for last resized CSV per browser session }
68
72
 
69
73
  # single, reused formatter: inline styles, padding, rounded corners, scroll
70
74
  _FMT = _HtmlFmt(
@@ -122,6 +126,7 @@ def get_contrast_color(hex_color: str) -> str:
122
126
  def render_chat_history(smx):
123
127
  plottings_html = smx.get_plottings()
124
128
  messages = smx.get_chat_history() or []
129
+
125
130
  chat_html = ""
126
131
  if not messages and not plottings_html:
127
132
  chat_html += f"""
@@ -171,27 +176,43 @@ def setup_routes(smx):
171
176
  os.makedirs(DATA_FOLDER, exist_ok=True)
172
177
 
173
178
  MEDIA_FOLDER = os.path.join(_CLIENT_DIR, "uploads", "media")
174
- if not os.path.exists(MEDIA_FOLDER):
175
- os.makedirs(MEDIA_FOLDER)
179
+
180
+ # Ensure media subfolders (images/videos + generated)
181
+ MEDIA_IMAGES_UPLOADED = os.path.join(MEDIA_FOLDER, "images", "uploaded")
182
+ MEDIA_IMAGES_GENERATED = os.path.join(MEDIA_FOLDER, "images", "generated")
183
+ MEDIA_IMAGES_GENERATED_ICONS = os.path.join(MEDIA_IMAGES_GENERATED, "icons")
184
+ MEDIA_IMAGES_THUMBS = os.path.join(MEDIA_IMAGES_GENERATED, "thumbs")
185
+
186
+ MEDIA_VIDEOS_UPLOADED = os.path.join(MEDIA_FOLDER, "videos", "uploaded")
187
+ MEDIA_FILES_UPLOADED = os.path.join(MEDIA_FOLDER, "files", "uploaded")
188
+
189
+ for _p in [MEDIA_IMAGES_UPLOADED,
190
+ MEDIA_IMAGES_GENERATED,
191
+ MEDIA_IMAGES_GENERATED_ICONS,
192
+ MEDIA_IMAGES_THUMBS,
193
+ MEDIA_VIDEOS_UPLOADED,
194
+ MEDIA_FILES_UPLOADED,
195
+ ]:
196
+ os.makedirs(_p, exist_ok=True)
176
197
 
177
198
  def _evict_profile_caches_by_name(prof_name: str) -> None:
178
- """
179
- Clear any in-memory profile cache on `smx` that points to the deleted profile.
180
- Future-proof: it scans all attributes and clears any dict whose 'name' matches.
181
- """
182
- if not prof_name:
183
- return
184
- try:
185
- for attr in dir(smx):
186
- # be generous: match anything that mentions 'profile' in its name
187
- if "profile" not in attr.lower():
188
- continue
189
- val = getattr(smx, attr, None)
190
- if isinstance(val, dict) and val.get("name") == prof_name:
191
- setattr(smx, attr, {}) # drop just this one; others untouched
192
- except Exception:
193
- # never let cache eviction break the request path
194
- pass
199
+ """
200
+ Clear any in-memory profile cache on `smx` that points to the deleted profile.
201
+ Future-proof: it scans all attributes and clears any dict whose 'name' matches.
202
+ """
203
+ if not prof_name:
204
+ return
205
+ try:
206
+ for attr in dir(smx):
207
+ # be generous: match anything that mentions 'profile' in its name
208
+ if "profile" not in attr.lower():
209
+ continue
210
+ val = getattr(smx, attr, None)
211
+ if isinstance(val, dict) and val.get("name") == prof_name:
212
+ setattr(smx, attr, {}) # drop just this one; others untouched
213
+ except Exception:
214
+ # never let cache eviction break the request path
215
+ pass
195
216
 
196
217
  @smx.app.after_request
197
218
  def _set_session_cookie(resp):
@@ -263,10 +284,7 @@ def setup_routes(smx):
263
284
  font-size: clamp(1.4rem, 1.8vw, 1.8rem);
264
285
  margin-right: 0;
265
286
  }}
266
- .logo img {{
267
- display: block;
268
- width: clamp(1.4rem, 1.8vw, 1.8rem);
269
- }}
287
+
270
288
  .nav-left a {{
271
289
  color: {smx.theme["nav_text"]};
272
290
  text-decoration: none;
@@ -291,9 +309,13 @@ def setup_routes(smx):
291
309
  }}
292
310
  /* Hamburger button (hidden on desktop) */
293
311
  #hamburger-btn {{
294
- display: none;
295
- width: clamp(140px, 20vw, 240px);
312
+ display: none; /* shown only in mobile media query */
313
+ width: auto;
314
+ height: 40px;
315
+ margin-left: auto; /* push it to the far right */
316
+ padding: 0;
296
317
  font-size: 2rem;
318
+ line-height: 1;
297
319
  background: none;
298
320
  border: none;
299
321
  color: {smx.theme["nav_text"]};
@@ -497,6 +519,11 @@ def setup_routes(smx):
497
519
  box-sizing: border-box;
498
520
  }}
499
521
  }}
522
+ @media (max-width:900px){{
523
+ #chat-history {{
524
+ padding-top: 62px;
525
+ }}
526
+ }}
500
527
  </style>
501
528
 
502
529
  <!-- Add MathJax -->
@@ -591,32 +618,87 @@ def setup_routes(smx):
591
618
  dst = (href or "/").rstrip("/") or "/"
592
619
  return cur == dst or cur.startswith(dst + "/")
593
620
 
621
+ # Pull nav metadata from DB. Fail-soft if anything goes wrong.
622
+ try:
623
+ nav_meta = db.get_page_nav_map()
624
+ except Exception as e:
625
+ smx.warning(f"get_page_nav_map failed: {e}")
626
+ nav_meta = {}
627
+
628
+ def _page_label(name: str) -> str:
629
+ meta = nav_meta.get(name.lower()) or {}
630
+ label = (meta.get("nav_label") or "").strip()
631
+ return label or name.capitalize()
632
+
633
+ def _page_visible(name: str) -> bool:
634
+ meta = nav_meta.get(name.lower())
635
+ # Default behaviour: if there's no row, we show it.
636
+ if not meta:
637
+ return True
638
+ return bool(meta.get("show_in_nav", True))
639
+
640
+ def _page_order(name: str) -> int:
641
+ meta = nav_meta.get(name.lower()) or {}
642
+ order_val = meta.get("nav_order")
643
+ try:
644
+ return int(order_val)
645
+ except (TypeError, ValueError):
646
+ # Pages without explicit order go to the end, sorted by label
647
+ return 10_000
648
+
594
649
  # Build nav links with active class
595
650
  nav_items = []
596
- for page in smx.pages:
651
+
652
+ # Sort pages by nav_order first, then by label
653
+ pages_sorted = sorted(
654
+ smx.pages,
655
+ key=lambda nm: (_page_order(nm), _page_label(nm).lower()),
656
+ )
657
+
658
+ for page in pages_sorted:
659
+ if not _page_visible(page):
660
+ continue
597
661
  href = f"/page/{page}"
598
662
  active = " active" if _is_active(href) else ""
599
663
  aria = ' aria-current="page"' if active else ""
600
- nav_items.append(f'<a href="{href}" class="{active.strip()}"{aria}>{page.capitalize()}</a>')
664
+ label = _page_label(page)
665
+ nav_items.append(
666
+ f'<a href="{href}" class="{active.strip()}"{aria}>{label}</a>'
667
+ )
601
668
 
669
+ # # 1) Custom pages from smx.pages, filtered by show_in_nav
670
+ # for page in smx.pages:
671
+ # if not _page_visible(page):
672
+ # continue
673
+ # href = f"/page/{page}"
674
+ # active = " active" if _is_active(href) else ""
675
+ # aria = ' aria-current="page"' if active else ""
676
+ # label = _page_label(page)
677
+ # nav_items.append(
678
+ # f'<a href="{href}" class="{active.strip()}"{aria}>{label}</a>'
679
+ # )
680
+
681
+ # 2) Fixed items from string_navbar_items (unchanged, except Dashboard label)
602
682
  for st in string_navbar_items:
603
683
  slug = st.lower().replace(" ", "_")
604
684
  href = f"/{slug}"
605
685
  active = " active" if _is_active(href) else ""
606
686
  aria = ' aria-current="page"' if active else ""
607
- if st == "Dashboard":
608
- st = "MLearning"
687
+ label = "MLearning" if st == "Dashboard" else st
609
688
 
610
689
  # Only show Admin link to admins/superadmins
611
690
  if slug in ("admin", "admin_panel", "adminpanel"):
612
691
  role = session.get("role")
613
692
  if role not in ("admin", "superadmin"):
614
693
  continue
615
- nav_items.append(f'<a href="{href}" class="{active.strip()}"{aria}>{st}</a>')
694
+
695
+ nav_items.append(
696
+ f'<a href="{href}" class="{active.strip()}"{aria}>{label}</a>'
697
+ )
616
698
 
617
699
  nav_links = "".join(nav_items)
618
700
 
619
- theme_link = ''
701
+ theme_link = ""
620
702
  if smx.theme_toggle_enabled:
621
703
  theme_link = '<a href="/toggle_theme">Theme</a>'
622
704
 
@@ -629,7 +711,6 @@ def setup_routes(smx):
629
711
  '</form>'
630
712
  )
631
713
  else:
632
- # Only show Register link if the consumer app explicitly enabled it.
633
714
  reg_link = ""
634
715
  if getattr(smx, "registration_enabled", False):
635
716
  reg_link = f'|<a href="{url_for("register")}" class="nav-link">Register</a>'
@@ -667,7 +748,6 @@ def setup_routes(smx):
667
748
  {hamburger_btn}
668
749
  </nav>
669
750
  {mobile_nav}
670
- {hamburger_btn}
671
751
  """
672
752
 
673
753
  def footer_html():
@@ -3047,7 +3127,7 @@ def setup_routes(smx):
3047
3127
  }) + "\n\n"
3048
3128
 
3049
3129
  except GeneratorExit:
3050
- smx.info("Client aborted the stream.")
3130
+ return "Client aborted the stream."
3051
3131
  except Exception as e:
3052
3132
  smx.error(f"Stream error: {e}")
3053
3133
  yield "data: " + json.dumps({"event": "error", "error": str(e)}) + "\n\n"
@@ -3078,7 +3158,7 @@ def setup_routes(smx):
3078
3158
  yield "data: " + json.dumps({ "event": "cancelled" }) + "\n\n"
3079
3159
 
3080
3160
  try:
3081
- gen = smx.process_query_stream(**sa) # <- yes, this is where streaming is used
3161
+ gen = smx.process_query_stream(**sa)
3082
3162
  except Exception as e:
3083
3163
  smx.error(f"Could not start stream: {e}")
3084
3164
  return jsonify({"error": "stream_start_failed", "message": str(e)})
@@ -3246,6 +3326,10 @@ def setup_routes(smx):
3246
3326
  def upload_user_file():
3247
3327
  import uuid
3248
3328
  from flask import jsonify
3329
+
3330
+ if not getattr(smx, "user_files_enabled", False):
3331
+ return jsonify({"error": "user_files_disabled"}), 403
3332
+
3249
3333
  # Define the upload folder for user files.
3250
3334
  upload_folder = os.path.join(_CLIENT_DIR, "uploads", "user")
3251
3335
  if not os.path.exists(upload_folder):
@@ -3350,24 +3434,45 @@ def setup_routes(smx):
3350
3434
  }
3351
3435
  .admin-sidenav a:hover,.admin-sidenav a.active{background:#DADADA}
3352
3436
 
3353
- /* Main content with balanced margins */
3354
- @media (min-width: 901px){
3355
- .admin-main{
3356
- margin-left: calc(var(--sidenav-w) + 3px); /* 1px for the border */
3357
- margin-top: var(--nav-h);
3358
- margin-bottom: 0;
3359
- padding: 0 10px; /* keep your left gutter */
3360
- margin-right: 0 !important; /* stop over-wide total */
3361
- width: calc(100% - var(--sidenav-w)) !important; /* % not vw */
3362
- padding-right: var(--right) !important; /* keep your right gutter */
3363
- box-sizing: border-box;
3364
- max-width: 100%;
3437
+ /* Admin overlay + toggle (desktop: hidden) */
3438
+ .admin-scrim{
3439
+ position: fixed;
3440
+ inset: 0;
3441
+ background: rgba(0,0,0,.25);
3442
+ z-index: 1000;
3443
+ opacity: 0;
3444
+ pointer-events: none;
3445
+ transition: opacity .2s ease;
3446
+ }
3447
+ .admin-scrim.show{
3448
+ opacity: 1;
3449
+ pointer-events: auto;
3450
+ }
3451
+
3452
+ .admin-sidebar-toggle{
3453
+ display: none; /* only visible on mobile */
3454
+ }
3455
+
3456
+ /* shared with dashboard drawer logic */
3457
+ body.no-scroll{
3458
+ overflow: hidden;
3365
3459
  }
3366
- @media (max-width: 1200px) {
3367
- body {
3368
- padding-top: 0;
3460
+
3461
+ /* Main content with balanced margins (desktop ≥ 901px) */
3462
+ @media (min-width: 901px){
3463
+ .admin-main{
3464
+ margin-left: calc(var(--sidenav-w) + 3px); /* 1px for the border */
3465
+ margin-top: var(--nav-h);
3466
+ margin-bottom: 0;
3467
+ padding: 0 10px; /* keep your left gutter */
3468
+ margin-right: 0 !important; /* stop over-wide total */
3469
+ width: calc(100% - var(--sidenav-w)) !important; /* % not vw */
3470
+ padding-right: var(--right) !important; /* keep your right gutter */
3471
+ box-sizing: border-box;
3472
+ max-width: 100%;
3369
3473
  }
3370
3474
  }
3475
+
3371
3476
  /* Section demarcation */
3372
3477
  .section{
3373
3478
  background: var(--section-bg);
@@ -3423,7 +3528,7 @@ def setup_routes(smx):
3423
3528
  .span-12 { grid-column: span 12; }
3424
3529
 
3425
3530
  /* Lists */
3426
- .catalog-list{max-height:120px;overflow:auto;margin:0;padding:0;list-style:none}
3531
+ .catalog-list{max-height:200px;overflow:auto;margin:0;padding:0;list-style:none}
3427
3532
  .catalog-list li{
3428
3533
  display:flex;align-items:center;justify-content:space-between;gap:4px;
3429
3534
  padding:1px 2px;border-bottom:1px solid #eee;font-size:.7rem;
@@ -3467,22 +3572,67 @@ def setup_routes(smx):
3467
3572
  }
3468
3573
  }
3469
3574
 
3470
- /* Mobile */
3575
+ /* Mobile: off-canvas drawer from the left (like dashboard) */
3471
3576
  @media (max-width: 900px){
3472
- .admin-sidenav{ width: var(--sidenav-w-sm); }
3577
+ .admin-sidenav{
3578
+ position: fixed;
3579
+ top: var(--nav-h);
3580
+ left: 0;
3581
+ width: 24vw; /* narrower drawer */
3582
+ max-width: 96px; /* cap on larger phones */
3583
+ height: calc(100vh - var(--nav-h));
3584
+ transform: translateX(-100%);
3585
+ transition: transform .28s ease;
3586
+ z-index: 1100;
3587
+ border-radius: 0 10px 10px 0;
3588
+ }
3589
+ .admin-sidenav.open{
3590
+ transform: translateX(0);
3591
+ }
3592
+
3473
3593
  .admin-main{
3474
- margin-top: var(--nav-h);
3475
- margin-left: calc(var(--sidenav-w-sm) - 1px); /* 1px for the border */
3476
- margin-right: 4px;
3477
- width: calc(100% - var(--sidenav-w-sm));
3478
- padding: 0;
3594
+ margin-left: 0;
3595
+ margin-right: 0;
3596
+ width: 100%;
3597
+ padding: 8px 8px 16px;
3479
3598
  box-sizing: border-box;
3480
- max-width: 100%;
3599
+ max-width: 100%;
3600
+ }
3601
+
3602
+ /* Floating blue toggle button (hamburger / close) */
3603
+ .admin-sidebar-toggle{
3604
+ position: fixed;
3605
+ top: calc(var(--nav-h) + 8px); /* sit just below the blue header */
3606
+ left: 10px;
3607
+ z-index: 1200;
3608
+ display: inline-flex;
3609
+ align-items: center;
3610
+ justify-content: center;
3611
+ width: 40px;
3612
+ height: 40px;
3613
+ border: 0;
3614
+ border-radius: 10px;
3615
+ background: #0d6efd;
3616
+ color: #fff;
3617
+ box-shadow: 0 4px 14px rgba(0,0,0,.18);
3618
+ cursor: pointer;
3619
+ }
3620
+ .admin-sidebar-toggle::before{
3621
+ content: "☰";
3622
+ font-size: 22px;
3623
+ line-height: 1;
3624
+ }
3625
+ .admin-sidebar-toggle.is-open::before{
3626
+ content: "✕";
3627
+ }
3628
+
3629
+ /* Stack cards one per row on narrow screens */
3630
+ .span-2, .span-3, .span-4, .span-5, .span-6, .span-7,
3631
+ .span-8, .span-9, .span-10, .span-12 {
3632
+ grid-column: span 12;
3481
3633
  }
3482
-
3483
- /* force all grid items to stack */
3484
- .span-3, .span-4, .span-6, .span-8, .span-12 { grid-column: span 12; }
3485
3634
  }
3635
+
3486
3636
  /* Prevent any inner block from insisting on a width that causes overflow */
3487
3637
  .admin-shell .card, .admin-grid { min-width: 0; }
3488
3638
 
@@ -3558,12 +3708,13 @@ def setup_routes(smx):
3558
3708
  .catalog-list li:hover {
3559
3709
  background: #D3E3D3;
3560
3710
  }
3561
- #users > div > div > ul > li > form > button {
3711
+ #users > div > div > ul > li > form > button {
3562
3712
  font-size: 0.7rem;
3563
3713
  margin: 0;
3564
3714
  padding: 0 !important;
3565
3715
  border: 0.5px dashed gray;
3566
3716
  }
3717
+
3567
3718
  /* Fix: stop inputs/selects inside cards spilling out (desktop & tablet) */
3568
3719
  .admin-shell .card > * { min-width: 0; }
3569
3720
  .admin-shell .card input,
@@ -3576,13 +3727,53 @@ def setup_routes(smx):
3576
3727
  }
3577
3728
  .admin-shell .card input:not([type="checkbox"]):not([type="radio"]),
3578
3729
  .admin-shell .card select,
3579
- .admin-shell .card textarea{
3730
+ .admin-shell .card textarea {
3580
3731
  display:block;
3581
3732
  width:100%;
3582
3733
  max-width:100%;
3583
3734
  box-sizing:border-box;
3584
3735
  }
3585
3736
 
3737
+ /* ── Manage Pages overrides: compact single-row controls inside the list ── */
3738
+ #pages .catalog-list li {
3739
+ align-items: center;
3740
+ }
3741
+
3742
+ #pages .catalog-list li form {
3743
+ display: flex;
3744
+ align-items: center;
3745
+ justify-content: space-between;
3746
+ gap: 0.4rem;
3747
+ width: 100%;
3748
+ flex-wrap: nowrap;
3749
+ }
3750
+
3751
+ #pages .catalog-list li form input,
3752
+ #pages .catalog-list li form select,
3753
+ #pages .catalog-list li form button {
3754
+ display: inline-block;
3755
+ width: auto;
3756
+ max-width: 10rem;
3757
+ box-sizing: border-box;
3758
+ }
3759
+
3760
+ #pages .catalog-list li form input[type="text"] {
3761
+ flex: 1 1 160px; /* nav label / title can grow */
3762
+ }
3763
+
3764
+ #pages .catalog-list li form input[type="number"] {
3765
+ width: 3rem;
3766
+ flex: 0 0 auto; /* small fixed width for order */
3767
+ }
3768
+
3769
+ #pages .catalog-list li form label {
3770
+ display: inline-flex;
3771
+ align-items: center;
3772
+ gap: 0.3rem;
3773
+ white-space: nowrap;
3774
+ margin: 0;
3775
+ }
3776
+
3586
3777
  /* Restore normal checkbox/radio sizing & alignment */
3587
3778
  .admin-shell .card input[type="checkbox"],
3588
3779
  .admin-shell .card input[type="radio"]{
@@ -3602,18 +3793,13 @@ def setup_routes(smx):
3602
3793
  }
3603
3794
  /* If fixed and its height is constant (e.g., 56px) */
3604
3795
  body { padding-top: 46px; } /* make room for the bar */
3605
- .admin-main { margin-top: 0; } /* remove the manual bump */
3606
- .admin-sidenav { /* keep the sidebar aligned */
3607
- top: 56px;
3608
- height: calc(100vh - 56px);
3609
- }
3796
+
3610
3797
  #del-embed-btn, .del-btn {
3611
3798
  padding: 0;
3612
3799
  font-size: 0.6rem;
3613
3800
  border: none;
3614
3801
  text-decoration: none;
3615
3802
  }
3616
-
3617
3803
  </style>
3618
3804
  """
3619
3805
 
@@ -3665,15 +3851,199 @@ def setup_routes(smx):
3665
3851
  f"Generated {total_chunks} chunk(s)."
3666
3852
  )
3667
3853
 
3854
+
3668
3855
  elif action == "add_page":
3669
- page_name = request.form.get("page_name", "").strip()
3670
- page_name = page_name.lower()
3671
- site_desc = request.form.get("site_desc", "").strip()
3672
- if site_desc != "":
3856
+ # Core fields
3857
+ page_name = (request.form.get("page_name") or "").strip().lower()
3858
+
3859
+ def _slugify(s: str) -> str:
3860
+ s = (s or "").strip().lower()
3861
+ s = s.replace("_", "-")
3862
+ s = re.sub(r"\s+", "-", s)
3863
+ s = re.sub(r"[^a-z0-9\-]+", "", s)
3864
+ s = re.sub(r"-{2,}", "-", s).strip("-")
3865
+ return s or "page"
3866
+ requested_slug = _slugify(page_name)
3867
+ base_slug = requested_slug
3868
+
3869
+ # Find a free slug (auto-suffix)
3870
+ final_slug = base_slug
3871
+ n = 2
3872
+ while final_slug in (smx.pages or {}):
3873
+ final_slug = f"{base_slug}-{n}"
3874
+ n += 1
3875
+ page_name = final_slug
3876
+
3877
+ site_desc = (request.form.get("site_desc") or "").strip()
3878
+
3879
+ # Nav-related fields from the form
3880
+ show_in_nav_raw = request.form.get("show_in_nav")
3881
+ show_in_nav = bool(show_in_nav_raw)
3882
+ nav_label = (request.form.get("nav_label") or "").strip()
3883
+
3884
+
3885
+ # Compile to modern HTML with icons + animations
3886
+ # Use instance website description unless the form provides a new one
3887
+ if site_desc:
3673
3888
  smx.set_website_description(site_desc)
3674
- page_content_html = _genpage.generate_page_html(page_name, smx.website_description)
3675
- if page_name and page_name not in smx.pages:
3676
- db.add_page(page_name, page_content_html)
3889
+
3890
+ base_slug = (page_name or "").strip().lower()
3891
+ if not base_slug:
3892
+ flash("Page name is required.", "error")
3893
+ return redirect(url_for("admin_panel"))
3894
+
3895
+ # Auto-suffix if slug clashes
3896
+ final_slug = base_slug
3897
+ if final_slug in (smx.pages or {}):
3898
+ n = 2
3899
+ while f"{base_slug}-{n}" in (smx.pages or {}):
3900
+ n += 1
3901
+ final_slug = f"{base_slug}-{n}"
3902
+
3903
+ # Pull Pixabay key if you have it in DB (best-effort)
3904
+ pixabay_key = ""
3905
+ try:
3906
+ if hasattr(db, "get_secret"):
3907
+ pixabay_key = db.get_secret("PIXABAY_API_KEY") or ""
3908
+ except Exception:
3909
+ pixabay_key = ""
3910
+
3911
+ # Agentic generation (Gemini → plan → validate → Pixabay → compile)
3912
+ result = agentic_generate_page(
3913
+ page_slug=final_slug,
3914
+ website_description=smx.website_description,
3915
+ client_dir=_CLIENT_DIR,
3916
+ pixabay_api_key=pixabay_key,
3917
+ llm_profile=smx.current_profile("coder"),
3918
+ )
3919
+
3920
+ page_content_html = result["html"]
3921
+ layout_plan = result["plan"]
3922
+
3923
+ # Persist page content
3924
+ if final_slug not in smx.pages:
3925
+ db.add_page(final_slug, page_content_html)
3926
+ smx.pages = db.get_pages()
3927
+ else:
3928
+ db.update_page(final_slug, final_slug, page_content_html)
3929
+ smx.pages = db.get_pages()
3930
+
3931
+ # If you have page_layouts support, store the plan for the builder
3932
+ try:
3933
+ if hasattr(db, "upsert_page_layout"):
3934
+ db.upsert_page_layout(final_slug, json.dumps(layout_plan), is_detached=False)
3935
+ except Exception as e:
3936
+ smx.warning(f"upsert_page_layout failed for '{final_slug}': {e}")
3937
+
3938
+ # Nav label default
3939
+ if not nav_label:
3940
+ nav_label = final_slug.capitalize()
3941
+
3942
+ # Compute default nav order
3943
+ nav_order = None
3944
+ try:
3945
+ nav_meta_all = db.get_page_nav_map()
3946
+ existing_orders = [
3947
+ meta.get("nav_order")
3948
+ for meta in nav_meta_all.values()
3949
+ if meta.get("nav_order") is not None
3950
+ ]
3951
+ nav_order = (max(existing_orders) + 1) if existing_orders else 1
3952
+ except Exception as e:
3953
+ smx.warning(f"Could not compute nav order for '{final_slug}': {e}")
3954
+ nav_order = None
3955
+
3956
+ try:
3957
+ db.set_page_nav(
3958
+ final_slug,
3959
+ show_in_nav=show_in_nav,
3960
+ nav_label=nav_label,
3961
+ nav_order=nav_order,
3962
+ )
3963
+ except Exception as e:
3964
+ smx.warning(f"set_page_nav failed for '{final_slug}': {e}")
3965
+
3966
+ # Show banner only on builder/edit page after generation
3967
+ session["published_as"] = final_slug
3968
+ return redirect(url_for("edit_page", page_name=final_slug, published_as=final_slug))
3969
+
3970
+ elif action == "update_page_nav":
3971
+ # Update nav visibility / label / order for an existing page
3972
+ page_name = (request.form.get("page_name") or "").strip().lower()
3973
+ show_raw = request.form.get("show_in_nav")
3974
+ show_in_nav = bool(show_raw)
3975
+ nav_label = (request.form.get("nav_label") or "").strip()
3976
+ nav_order_raw = (request.form.get("nav_order") or "").strip()
3977
+
3978
+ nav_order = None
3979
+ if nav_order_raw:
3980
+ try:
3981
+ nav_order = int(nav_order_raw)
3982
+ except ValueError:
3983
+ nav_order = None
3984
+
3985
+ if page_name:
3986
+ if not nav_label:
3987
+ nav_label = page_name.capitalize()
3988
+ try:
3989
+ db.set_page_nav(
3990
+ page_name,
3991
+ show_in_nav=show_in_nav,
3992
+ nav_label=nav_label,
3993
+ nav_order=nav_order,
3994
+ )
3995
+ except Exception as e:
3996
+ smx.warning(f"update_page_nav failed for '{page_name}': {e}")
3997
+
3998
+ return redirect(url_for("admin_panel"))
3999
+
4000
+ elif action == "reorder_pages":
4001
+ """
4002
+ Persist a new navigation order for pages.
4003
+ Expects a comma-separated list of page names in `page_order_csv`.
4004
+ """
4005
+ order_csv = (request.form.get("page_order_csv") or "").strip()
4006
+ if order_csv:
4007
+ # normalise and dedupe while preserving order
4008
+ raw_names = [n.strip() for n in order_csv.split(",") if n.strip()]
4009
+ seen = set()
4010
+ ordered_names = []
4011
+ for nm in raw_names:
4012
+ if nm in seen:
4013
+ continue
4014
+ seen.add(nm)
4015
+ ordered_names.append(nm)
4016
+
4017
+ try:
4018
+ nav_meta = db.get_page_nav_map()
4019
+ except Exception as e:
4020
+ smx.warning(f"admin_panel: get_page_nav_map failed while reordering pages: {e}")
4021
+ nav_meta = {}
4022
+
4023
+ order_idx = 1
4024
+ for name in ordered_names:
4025
+ # Try to find any existing meta for this page
4026
+ meta = (
4027
+ nav_meta.get(name)
4028
+ or nav_meta.get(name.lower())
4029
+ or {}
4030
+ )
4031
+ show_in_nav = meta.get("show_in_nav", True)
4032
+ nav_label = meta.get("nav_label") or name.capitalize()
4033
+
4034
+ try:
4035
+ db.set_page_nav(
4036
+ name,
4037
+ show_in_nav=show_in_nav,
4038
+ nav_label=nav_label,
4039
+ nav_order=order_idx,
4040
+ )
4041
+ except Exception as e:
4042
+ smx.warning(f"admin_panel: set_page_nav failed for {name}: {e}")
4043
+ order_idx += 1
4044
+
4045
+ # Always bounce back to the admin panel (avoid re-POST)
4046
+ return redirect(url_for("admin_panel"))
3677
4047
 
3678
4048
 
3679
4049
  elif action == "save_llm":
@@ -3699,7 +4069,7 @@ def setup_routes(smx):
3699
4069
  prov = request.form["provider"]
3700
4070
  model = request.form["model"]
3701
4071
  tag = request.form["purpose"]
3702
- desc = request.form["desc"]
4072
+ desc = request.form["desc"]
3703
4073
 
3704
4074
  if not any(r for r in catalog if r["provider"] == prov and r["model"] == model):
3705
4075
  flash("Provider/model not in catalog", "error")
@@ -3710,7 +4080,7 @@ def setup_routes(smx):
3710
4080
  provider = request.form.get("provider", "").strip(),
3711
4081
  model = request.form.get("model", "").strip(),
3712
4082
  api_key = request.form.get("api_key", "").strip(),
3713
- purpose = request.form.get("purpose", "").strip() or "general",
4083
+ purpose = request.form.get("purpose", "").strip(),
3714
4084
  desc = request.form.get("desc", "").strip(),
3715
4085
  )
3716
4086
  _prof.refresh_profiles_cache()
@@ -3780,7 +4150,7 @@ def setup_routes(smx):
3780
4150
  elif action == "create_user":
3781
4151
  viewer_role = (session.get("role") or "").lower()
3782
4152
  if viewer_role not in ("admin", "superadmin"):
3783
- flash("You don't have permission to create users.", "error")
4153
+ flash("You are not authorised to create user.", "error")
3784
4154
  else:
3785
4155
  email = (request.form.get("email") or "").strip()
3786
4156
  username = (request.form.get("username") or "").strip()
@@ -3831,7 +4201,7 @@ def setup_routes(smx):
3831
4201
 
3832
4202
  elif action == "confirm_delete_user":
3833
4203
  if (session.get("role") or "").lower() != "superadmin":
3834
- flash("Only superadmin can delete accounts.", "error")
4204
+ flash("You are not authorised to delete accounts.", "error")
3835
4205
  else:
3836
4206
  session["pending_delete_user_id"] = int(request.form.get("user_id") or 0)
3837
4207
  flash("Confirm deletion below.", "warning")
@@ -3841,7 +4211,7 @@ def setup_routes(smx):
3841
4211
 
3842
4212
  elif action == "delete_user":
3843
4213
  if (session.get("role") or "").lower() != "superadmin":
3844
- flash("You don't have permission to delete account.", "error")
4214
+ flash("You are not authorised to delete account.", "error")
3845
4215
  else:
3846
4216
  target_id = session.get("pending_delete_user_id")
3847
4217
  if target_id:
@@ -3873,7 +4243,7 @@ def setup_routes(smx):
3873
4243
  # ────────────────────────────────────────────────────────────────────────────────
3874
4244
  embedding_model = _llms.load_embed_model()
3875
4245
  embeddings_setup_card = f"""
3876
- <div class="card span-4">
4246
+ <div class="card span-3">
3877
4247
  <h4>Setup Embedding Model</h4>
3878
4248
  <form method="post" style="display:inline-block; margin-right:8px;">
3879
4249
  <input type="hidden" name="action" value="save_llm">
@@ -3934,7 +4304,7 @@ def setup_routes(smx):
3934
4304
  # LLMs
3935
4305
  # ────────────────────────────────────────────────────────────────────────────────
3936
4306
  Add_model_catalog_card = f"""
3937
- <div class="card span-4">
4307
+ <div class="card span-3">
3938
4308
  <h3>Add Model To Catalogue</h3>
3939
4309
  <form method="post" style="margin-bottom:0.5rem;">
3940
4310
  <label for="catalog_prov">Provider</label>
@@ -4065,7 +4435,7 @@ def setup_routes(smx):
4065
4435
  """
4066
4436
 
4067
4437
  models_catalog_list_card = f"""
4068
- <div class="card span-4">
4438
+ <div class="card span-6">
4069
4439
  <h4>Models Catalogue</h4>
4070
4440
  <ul class="catalog-list">
4071
4441
  {cat_items or "<li class='li-row'>No models yet.</li>"}
@@ -4103,7 +4473,7 @@ def setup_routes(smx):
4103
4473
 
4104
4474
  <input type='hidden' id='purpose-field' name='purpose'>
4105
4475
  <input type='hidden' id='desc-field' name='desc'>
4106
-
4476
+ <br>
4107
4477
  <button class='btn btn-primary' type='submit' name='action' value='add_profile'>Add / Update</button>
4108
4478
  </form>
4109
4479
  </div>
@@ -4135,15 +4505,21 @@ def setup_routes(smx):
4135
4505
  <ul class="catalog-list" style="padding-left:1rem; margin-bottom:0;">
4136
4506
  {profile_items or "<li class='li-row'>No profiles yet.</li>"}
4137
4507
  </ul>
4508
+
4509
+ <!-- Refresh button (reload admin page; anchor back to Models section) -->
4510
+ <div style="display:flex; justify-content:flex-end; margin-top:10px;">
4511
+ <a class="btn" href="/admin?refresh=profiles#models" title="Reload to refresh profiles list">Refresh</a>
4512
+ </div>
4138
4513
  </div>
4139
4514
  """
4140
4515
 
4516
+
4141
4517
  # ────────────────────────────────────────────────────────────────────────────────
4142
4518
  # SYSTEM FILES
4143
4519
  # ────────────────────────────────────────────────────────────────────────────────
4144
4520
  sys_files_card = f"""
4145
- <div class="card span-6">
4146
- <h4>Upload System Files (PDFs only)</h4>
4521
+ <div class="card span-3">
4522
+ <h4>Upload System Files<br>(PDFs only)</h4>
4147
4523
  <form id="form-upload" method="post" enctype="multipart/form-data" style="display:inline-block;">
4148
4524
  <input type="file" name="upload_files" accept=".pdf" multiple>
4149
4525
  <button type="submit" name="action" value="upload_files">Upload</button>
@@ -4174,7 +4550,7 @@ def setup_routes(smx):
4174
4550
  """
4175
4551
 
4176
4552
  manage_sys_files_card = f"""
4177
- <div class='card span-6'>
4553
+ <div class='card span-3'>
4178
4554
  <h4>Manage Company Files</h4>
4179
4555
  <ul class="catalog-list" style="list-style:none; padding-left:0; margin:0;">
4180
4556
  {sys_files_html or "<li>No company file has been uploaded yet.</li>"}
@@ -4189,48 +4565,105 @@ def setup_routes(smx):
4189
4565
  upload_msg = session.pop("upload_msg", "")
4190
4566
  alert_script = f"<script>alert('{upload_msg}');</script>" if upload_msg else ""
4191
4567
 
4568
+ # Load nav metadata (show_in_nav / nav_label) for existing pages
4569
+ try:
4570
+ nav_meta = db.get_page_nav_map()
4571
+ except Exception as e:
4572
+ smx.warning(f"get_page_nav_map failed in admin_panel: {e}")
4573
+ nav_meta = {}
4574
+
4192
4575
  pages_html = ""
4193
4576
  for p in smx.pages:
4577
+ meta = nav_meta.get(p.lower(), {})
4578
+ show_flag = meta.get("show_in_nav", True)
4579
+ label = meta.get("nav_label") or p.capitalize()
4580
+ nav_order_val = meta.get("nav_order")
4581
+ safe_label = html.escape(label, quote=True)
4582
+ order_display = "" if nav_order_val is None else html.escape(str(nav_order_val), quote=True)
4583
+ checked = "checked" if show_flag else ""
4584
+
4194
4585
  pages_html += f"""
4195
4586
  <li class="li-row" data-row-id="{p}">
4196
- <span>{p}</span>
4197
- <span style="float:right;">
4198
- <a class="edit-btn" href="/admin/edit/{p}" title="Edit {p}">🖊️</a>
4199
- <a href="#"
4200
- class="del-btn" title="Delete {p}"
4201
- data-action="open-delete-modal"
4202
- data-delete-url="/admin/delete.json"
4203
- data-delete-field="page_name"
4204
- data-delete-id="{p}"
4205
- data-delete-label="page {p}"
4206
- data-delete-extra='{{"resource":"page"}}'
4207
- data-delete-remove="[data-row-id='{p}']">🗑️</a>
4208
- </span>
4587
+ <form method="post" style="display:flex; align-items:center; gap:0.4rem; justify-content:space-between; width:100%;">
4588
+ <input type="hidden" name="action" value="update_page_nav">
4589
+ <input type="hidden" name="page_name" value="{p}">
4590
+ <span style="flex:0 0 auto;">{p}</span>
4591
+ <span style="flex:1 1 auto; text-align:right; font-size:0.75rem;">
4592
+ <label style="display:inline-flex; align-items:center; gap:0.25rem; margin-right:0.4rem;">
4593
+ <input type="checkbox" name="show_in_nav" value="1" {checked} style="margin:0; width:auto;">
4594
+ <span>Show</span>
4595
+ </label>
4596
+ <input
4597
+ type="number"
4598
+ name="nav_order"
4599
+ value="{order_display}"
4600
+ placeholder="#"
4601
+ min="0"
4602
+ style="width:3rem; font-size:0.75rem; padding:2px 4px; border-radius:4px; border:1px solid #ccc; text-align:right; margin-right:0.25rem;"
4603
+ >
4604
+ <input
4605
+ type="text"
4606
+ name="nav_label"
4607
+ value="{safe_label}"
4608
+ placeholder="Nav label"
4609
+ style="max-width:8.5rem; font-size:0.75rem; padding:2px 4px; border-radius:4px; border:1px solid #ccc;"
4610
+ >
4611
+ <button type="submit" style="font-size:0.7rem; padding:2px 6px; margin-left:0.25rem;">
4612
+ Save
4613
+ </button>
4614
+ </span>
4615
+ <span style="flex:0 0 auto; margin-left:0.4rem;">
4616
+ <a class="edit-btn" href="/admin/edit/{p}" title="Edit {p}">🖊️</a>
4617
+ <a href="#"
4618
+ class="del-btn" title="Delete {p}"
4619
+ data-action="open-delete-modal"
4620
+ data-delete-url="/admin/delete.json"
4621
+ data-delete-field="page_name"
4622
+ data-delete-id="{p}"
4623
+ data-delete-label="page {p}"
4624
+ data-delete-extra='{{"resource":"page"}}'
4625
+ data-delete-remove="[data-row-id='{p}']">🗑️</a>
4626
+ </span>
4627
+ </form>
4209
4628
  </li>
4210
4629
  """
4211
4630
 
4212
4631
  add_new_page_card = f"""
4213
- <div class="card span-9">
4214
- <h4>Add New Page</h4>
4215
- <form id="form-add-page" method="post">
4216
- <input type="text" name="page_name" placeholder="Page Name" required>
4217
- <textarea name="site_desc" placeholder="Website description"></textarea>
4218
- <div style="text-align:right;">
4219
- <button type="submit" name="action" value="add_page">Add Page</button>
4220
- </div>
4221
- </form>
4222
- </div>
4223
- """
4632
+ <div class="card span-12">
4633
+ <h4>Generate New Page</h4>
4634
+ <form id="add-page-form" method="post">
4635
+ <input type="hidden" name="action" value="add_page">
4636
+ <input type="text" name="page_name" placeholder="Page Name" required>
4637
+ <textarea name="site_desc" placeholder="Website description"></textarea>
4638
+ <div style="display:flex; align-items:center; justify-content:space-between; margin-top:0.35rem;">
4639
+ <label style="display:inline-flex; align-items:center; gap:0.4rem; font-size:0.8rem;">
4640
+ <input type="checkbox" name="show_in_nav" checked style="margin:0; width:auto;">
4641
+ <span>Show in nav</span>
4642
+ </label>
4643
+ <input
4644
+ type="text"
4645
+ name="nav_label"
4646
+ placeholder="Navigation label (optional)"
4647
+ style="font-size:0.8rem; padding:3px 6px; max-width:11rem;"
4648
+ >
4649
+ </div>
4650
+ <div style="text-align:right; margin-top:0.4rem;">
4651
+ <button id="add-page-btn" type="submit">Generate</button>
4652
+ </div>
4653
+ </form>
4654
+ </div>
4655
+ """
4224
4656
 
4225
4657
  manage_page_card = f"""
4226
- <div class="card span-3">
4658
+ <div class="card span-12">
4227
4659
  <h4>Manage Pages</h4>
4228
- <ul class="catalog-list">
4660
+ <ul id="pages-list" class="catalog-list">
4229
4661
  {pages_html or "<li>No page has been added yet.</li>"}
4230
4662
  </ul>
4231
4663
  </div>
4232
4664
  """
4233
4665
 
4666
+
4234
4667
  # ────────────────────────────────────────────────────────────────────────────────
4235
4668
  # USERS & ROLES
4236
4669
  # ────────────────────────────────────────────────────────────────────────────────
@@ -4528,16 +4961,61 @@ def setup_routes(smx):
4528
4961
  </section>
4529
4962
  """
4530
4963
 
4964
+ existing_secret_names = []
4965
+ try:
4966
+ existing_secret_names = db.list_secret_names()
4967
+ except Exception:
4968
+ existing_secret_names = []
4969
+
4970
+ pixabay_saved = False
4971
+ try:
4972
+ pixabay_saved = bool(db.get_secret("PIXABAY_API_KEY") or os.environ.get("PIXABAY_API_KEY"))
4973
+ except Exception:
4974
+ pixabay_saved = bool(os.environ.get("PIXABAY_API_KEY"))
4975
+
4976
+ secretes_link_card = f"""
4977
+ <div class="card span-3">
4978
+ <h4>Integrations (Secrets)</h4>
4979
+ <div style="font-size:.72rem;color:#555;margin-top:-6px;margin-bottom:10px;line-height:1.35;">
4980
+ Store secrete credentials.
4981
+ </div>
4982
+ <a href="{url_for('admin_secretes')}" class="btn">Manage secretes</a>
4983
+ </div>
4984
+ """
4985
+
4986
+ features_link_card = f"""
4987
+ <div class="card span-4">
4988
+ <h4>Feature toggles</h4>
4989
+ <div style="font-size:.72rem;color:#555;margin-top:-6px;margin-bottom:10px;line-height:1.35;">
4990
+ Turn streaming on/off and allow user file uploads in chat.
4991
+ </div>
4992
+ <a href="{url_for('admin_features')}" class="btn">Manage features</a>
4993
+ </div>
4994
+ """
4995
+
4996
+ branding_link_card = f"""
4997
+ <div class="card span-3">
4998
+ <h4>Branding</h4>
4999
+ <div style="font-size:.72rem;color:#555;margin-top:-6px;margin-bottom:10px;line-height:1.35;">
5000
+ Upload your company logo and favicon (PNG/JPG). Defaults are used if nothing is uploaded.
5001
+ </div>
5002
+ <a href="{url_for('admin_branding')}" class="btn">Manage branding</a>
5003
+ </div>
5004
+ """
5005
+
4531
5006
  system_section = f"""
4532
5007
  <section id="system" class="section">
4533
5008
  <h2>System</h2>
4534
5009
  <div class="admin-grid">
5010
+ {secretes_link_card}
5011
+ {branding_link_card}
5012
+ {features_link_card}
4535
5013
  {sys_files_card}
4536
5014
  {manage_sys_files_card}
4537
5015
  </div>
5016
+
4538
5017
  </section>
4539
5018
  """
4540
-
4541
5019
  users_section = f"""
4542
5020
  <section id="users" class="section">
4543
5021
  <h2>Users</h2>
@@ -4560,15 +5038,47 @@ def setup_routes(smx):
4560
5038
 
4561
5039
  admin_shell = f"""{admin_layout_css}
4562
5040
  <div class="admin-shell">
4563
- {side_nav}
4564
- <div class="admin-main">
4565
- {models_section}
4566
- {pages_section}
4567
- {system_section}
4568
- {users_section}
4569
- {audits_section}
5041
+ <div id="adminSidebarScrim" class="admin-scrim" aria-hidden="true"></div>
5042
+ {side_nav}
5043
+ <div class="admin-main">
5044
+ <button id="adminSidebarToggle"
5045
+ class="admin-sidebar-toggle"
5046
+ aria-label="Open admin menu"></button>
5047
+ {models_section}
5048
+ {pages_section}
5049
+ {system_section}
5050
+ {users_section}
5051
+ {audits_section}
5052
+ </div>
4570
5053
  </div>
4571
- </div>
5054
+ <script>
5055
+ document.addEventListener('DOMContentLoaded', function () {{
5056
+ const sidebar = document.querySelector('.admin-sidenav');
5057
+ const toggle = document.getElementById('adminSidebarToggle');
5058
+ const scrim = document.getElementById('adminSidebarScrim');
5059
+
5060
+ function setOpen(open) {{
5061
+ if (!sidebar || !toggle) return;
5062
+ sidebar.classList.toggle('open', open);
5063
+ toggle.classList.toggle('is-open', open);
5064
+ toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
5065
+ document.body.classList.toggle('no-scroll', open);
5066
+ if (scrim) scrim.classList.toggle('show', open);
5067
+ }}
5068
+
5069
+ if (toggle) {{
5070
+ toggle.addEventListener('click', function () {{
5071
+ setOpen(!sidebar.classList.contains('open'));
5072
+ }});
5073
+ }}
5074
+
5075
+ if (scrim) {{
5076
+ scrim.addEventListener('click', function () {{
5077
+ setOpen(false);
5078
+ }});
5079
+ }}
5080
+ }});
5081
+ </script>
4572
5082
  """
4573
5083
 
4574
5084
  # ─────────────────────────────────────────────────────────
@@ -4604,16 +5114,19 @@ def setup_routes(smx):
4604
5114
  {delete_modal_block}
4605
5115
 
4606
5116
  <!-- Profiles helper scripts -->
4607
- <script>
4608
- /* Name suggestions popover */
4609
- const nameExamples = {{
4610
- 'Administration': 'Admin',
4611
- 'Chatting': 'Chat',
4612
- 'Coding': 'Coding',
4613
- 'Vision-to-text': 'Vision2Text',
4614
- 'Summarisation': 'Summarisation',
4615
- 'Classification': 'Classification',
4616
- }};
5117
+ <script>
5118
+ /* Name suggestions popover */
5119
+ const purpose_tags = {PURPOSE_TAGS}
5120
+ const nameExamples = {{}};
5121
+ const capitalize = (s) =>
5122
+ s.charAt(0).toUpperCase() + s.slice(1);
5123
+ for (let i = 0; i < purpose_tags.length; i++) {{
5124
+ purpose_tags[i] = capitalize(purpose_tags[i]);
5125
+ const tag = purpose_tags[i]
5126
+ const key = tag;
5127
+ nameExamples[key] = tag;
5128
+ }}
5129
+
4617
5130
  const txt = document.getElementById('profile_name');
4618
5131
  const infoBtn = document.getElementById('name-help');
4619
5132
  const popover = document.getElementById('name-suggestions');
@@ -4739,7 +5252,7 @@ def setup_routes(smx):
4739
5252
 
4740
5253
  if (form) {{
4741
5254
  form.addEventListener('submit', function () {{
4742
- if (btn) {{ btn.disabled = true; btn.textContent = 'Adding…'; }}
5255
+ if (btn) {{ btn.disabled = true; btn.textContent = 'Generating…'; }}
4743
5256
  if (overlay) overlay.style.display = 'flex';
4744
5257
  }});
4745
5258
  }}
@@ -4749,7 +5262,7 @@ def setup_routes(smx):
4749
5262
  const o = document.getElementById('loader-overlay');
4750
5263
  if (o) o.style.display = 'none';
4751
5264
  const b = document.getElementById('add-page-btn');
4752
- if (b) {{ b.disabled = false; b.textContent = 'Add Page'; }}
5265
+ if (b) {{ b.disabled = false; b.textContent = 'Generate'; }}
4753
5266
  }});
4754
5267
  }});
4755
5268
  </script>
@@ -4891,33 +5404,311 @@ def setup_routes(smx):
4891
5404
  if(e.target === backdrop) closeModal();
4892
5405
  }});
4893
5406
  }})();
4894
- </script>
4895
- </body>
4896
- </html>
4897
- """,
4898
- flash_messages=get_flashed_messages(with_categories=True),
4899
- llm=embedding_model,
4900
- catalog=_llms.list_models(),
4901
- profiles=profiles
4902
- )
5407
+ </script>
4903
5408
 
4904
- @smx.app.route("/admin/delete.json", methods=["POST"])
4905
- def admin_delete_universal():
5409
+ <script>
5410
+ // Drag & drop reordering for the "Manage Pages" list
5411
+ document.addEventListener('DOMContentLoaded', function () {{
5412
+ const list = document.querySelector('#pages .catalog-list');
5413
+ if (!list) return;
4906
5414
 
4907
- role = (session.get("role") or "").lower()
4908
- if role != "superadmin":
4909
- return jsonify(ok=False, error="Not authorized"), 403
4910
- try:
4911
- # read resource first; don't require a generic 'id' for all resources
4912
- resource = (request.form.get("resource") or "").lower()
4913
- if not resource:
4914
- return jsonify(ok=False, error="missing resource"), 400
5415
+ let draggingEl = null;
4915
5416
 
4916
- rid = request.form.get("id") # optional; used by some branches
5417
+ function getPageName(li) {{
5418
+ if (!li) return '';
5419
+ if (li.dataset.pageName) return li.dataset.pageName;
4917
5420
 
4918
- if resource == "profile":
4919
- # profiles use 'profile_name' (or fallback to 'id' if you ever send it that way)
4920
- prof_name = request.form.get("profile_name") or rid
5421
+ // Prefer an explicit hidden input if present
5422
+ const hidden = li.querySelector('input[name="page_name"]');
5423
+ if (hidden && hidden.value) return hidden.value.trim();
5424
+
5425
+ // Fallback: first span's text
5426
+ const span = li.querySelector('span');
5427
+ if (span && span.textContent) return span.textContent.trim();
5428
+
5429
+ return '';
5430
+ }}
5431
+
5432
+ // Set up draggable behaviour
5433
+ list.querySelectorAll('li.li-row').forEach(function (li) {{
5434
+ const name = getPageName(li);
5435
+ if (!name) return;
5436
+
5437
+ li.dataset.pageName = name;
5438
+ li.setAttribute('draggable', 'true');
5439
+
5440
+ li.addEventListener('dragstart', function (e) {{
5441
+ draggingEl = li;
5442
+ li.classList.add('dragging');
5443
+ if (e.dataTransfer) {{
5444
+ e.dataTransfer.effectAllowed = 'move';
5445
+ e.dataTransfer.setData('text/plain', name);
5446
+ }}
5447
+ }});
5448
+
5449
+ li.addEventListener('dragend', function () {{
5450
+ li.classList.remove('dragging');
5451
+ draggingEl = null;
5452
+
5453
+ // After drop, collect new order and POST it
5454
+ const items = Array.from(list.querySelectorAll('li.li-row'));
5455
+ const names = items
5456
+ .map(function (node) {{ return getPageName(node); }})
5457
+ .filter(Boolean);
5458
+
5459
+ if (!names.length) return;
5460
+
5461
+ const fd = new FormData();
5462
+ fd.append('action', 'reorder_pages');
5463
+ fd.append('page_order_csv', names.join(','));
5464
+
5465
+ fetch('/admin', {{
5466
+ method: 'POST',
5467
+ body: fd,
5468
+ credentials: 'same-origin'
5469
+ }})
5470
+ .then(function (res) {{
5471
+ if (!res.ok) {{
5472
+ console.error('Failed to save page order', res.status);
5473
+ }}
5474
+ // Reload so navbar + list reflect the new order
5475
+ window.location.reload();
5476
+ }})
5477
+ .catch(function (err) {{
5478
+ console.error('Error saving page order', err);
5479
+ }});
5480
+ }});
5481
+
5482
+ li.addEventListener('dragover', function (e) {{
5483
+ if (!draggingEl || draggingEl === li) return;
5484
+ e.preventDefault();
5485
+
5486
+ const rect = li.getBoundingClientRect();
5487
+ const offsetY = e.clientY - rect.top;
5488
+ const before = offsetY < (rect.height / 2);
5489
+
5490
+ if (before) {{
5491
+ list.insertBefore(draggingEl, li);
5492
+ }}else {{
5493
+ list.insertBefore(draggingEl, li.nextSibling);
5494
+ }}
5495
+ }});
5496
+ }});
5497
+ }});
5498
+ </script>
5499
+
5500
+ </body>
5501
+ </html>
5502
+ """,
5503
+ flash_messages=get_flashed_messages(with_categories=True),
5504
+ llm=embedding_model,
5505
+ catalog=_llms.list_models(),
5506
+ profiles=profiles
5507
+ )
5508
+
5509
+
5510
+ @smx.app.route("/admin/secretes", methods=["GET", "POST"])
5511
+ def admin_secretes():
5512
+ role = (session.get("role") or "").lower()
5513
+ if role not in ("admin", "superadmin"):
5514
+ return jsonify({"error": "forbidden"}), 403
5515
+
5516
+ if request.method == "POST":
5517
+ action = (request.form.get("action") or "").strip()
5518
+
5519
+ if action == "save_secret":
5520
+ name = (request.form.get("secret_name") or "").strip()
5521
+ value = (request.form.get("secret_value") or "").strip()
5522
+
5523
+ if not name:
5524
+ flash("Secret name is required.")
5525
+ return redirect(url_for("admin_secretes"))
5526
+
5527
+ # We don’t allow saving blank values accidentally.
5528
+ if not value:
5529
+ flash("Secret value is required.")
5530
+ return redirect(url_for("admin_secretes"))
5531
+
5532
+ db.set_secret(name, value)
5533
+ flash(f"Saved: {name.upper()} ✓")
5534
+ return redirect(url_for("admin_secretes"))
5535
+
5536
+ if action == "delete_secret":
5537
+ name = (request.form.get("secret_name") or "").strip()
5538
+ if name:
5539
+ db.delete_secret(name)
5540
+ flash(f"Deleted: {name.upper()}")
5541
+ return redirect(url_for("admin_secretes"))
5542
+
5543
+ # GET
5544
+ names = []
5545
+ try:
5546
+ names = db.list_secret_names()
5547
+ except Exception:
5548
+ names = []
5549
+
5550
+ return render_template("admin_secretes.html", secret_names=names)
5551
+
5552
+
5553
+ @smx.app.route("/admin/branding", methods=["GET", "POST"])
5554
+ @admin_required
5555
+ def admin_branding():
5556
+ branding_dir = os.path.join(_CLIENT_DIR, "branding")
5557
+ os.makedirs(branding_dir, exist_ok=True)
5558
+
5559
+ allowed_ext = {".png", ".jpg", ".jpeg"}
5560
+ max_logo_bytes = 5 * 1024 * 1024 # 5 MB
5561
+ max_favicon_bytes = 1 * 1024 * 1024 # 1 MB
5562
+
5563
+ def _find(base: str):
5564
+ for ext in (".png", ".jpg", ".jpeg"):
5565
+ p = os.path.join(branding_dir, f"{base}{ext}")
5566
+ if os.path.exists(p):
5567
+ return f"{base}{ext}"
5568
+ return None
5569
+
5570
+ def _delete_all(base: str):
5571
+ for ext in (".png", ".jpg", ".jpeg"):
5572
+ p = os.path.join(branding_dir, f"{base}{ext}")
5573
+ if os.path.exists(p):
5574
+ try:
5575
+ os.remove(p)
5576
+ except Exception:
5577
+ pass
5578
+
5579
+ def _save_upload(field_name: str, base: str, max_bytes: int):
5580
+ f = request.files.get(field_name)
5581
+ if not f or not f.filename:
5582
+ return False, None
5583
+
5584
+ ext = os.path.splitext(f.filename.lower())[1].strip()
5585
+ if ext not in allowed_ext:
5586
+ return False, f"Invalid file type for {base}. Use PNG or JPG."
5587
+
5588
+ # size check
5589
+ try:
5590
+ f.stream.seek(0, os.SEEK_END)
5591
+ size = f.stream.tell()
5592
+ f.stream.seek(0)
5593
+ except Exception:
5594
+ size = None
5595
+
5596
+ if size is not None and size > max_bytes:
5597
+ return False, f"{base.capitalize()} is too large. Max {max_bytes // (1024*1024)} MB."
5598
+
5599
+ # Replace existing logo.* / favicon.*
5600
+ _delete_all(base)
5601
+
5602
+ out_path = os.path.join(branding_dir, f"{base}{ext}")
5603
+ try:
5604
+ f.save(out_path)
5605
+ except Exception as e:
5606
+ return False, f"Failed to save {base}: {e}"
5607
+
5608
+ return True, None
5609
+
5610
+ # POST actions
5611
+ if request.method == "POST":
5612
+ action = (request.form.get("action") or "upload").strip().lower()
5613
+
5614
+ if action == "reset":
5615
+ _delete_all("logo")
5616
+ _delete_all("favicon")
5617
+ try:
5618
+ smx._apply_branding_from_disk()
5619
+ except Exception:
5620
+ pass
5621
+ flash("Branding reset to defaults ✓")
5622
+ return redirect(url_for("admin_branding"))
5623
+
5624
+ ok1, err1 = _save_upload("logo_file", "logo", max_logo_bytes)
5625
+ ok2, err2 = _save_upload("favicon_file", "favicon", max_favicon_bytes)
5626
+
5627
+ if err1:
5628
+ flash(err1, "error")
5629
+ if err2:
5630
+ flash(err2, "error")
5631
+
5632
+ if ok1 or ok2:
5633
+ try:
5634
+ smx._apply_branding_from_disk()
5635
+ except Exception:
5636
+ pass
5637
+ flash("Branding updated ✓")
5638
+
5639
+ return redirect(url_for("admin_branding"))
5640
+
5641
+ # GET: show current status
5642
+ logo_fn = _find("logo")
5643
+ fav_fn = _find("favicon")
5644
+
5645
+ cache_bust = int(time.time())
5646
+
5647
+ logo_url = f"/branding/{logo_fn}?v={cache_bust}" if logo_fn else None
5648
+ favicon_url = f"/branding/{fav_fn}?v={cache_bust}" if fav_fn else None
5649
+
5650
+ default_logo_html = getattr(smx, "_default_site_logo", smx.site_logo)
5651
+ default_favicon_url = getattr(smx, "_default_favicon", smx.favicon)
5652
+
5653
+ return render_template(
5654
+ "admin_branding.html",
5655
+ logo_url=logo_url,
5656
+ favicon_url=favicon_url,
5657
+ default_logo_html=Markup(default_logo_html),
5658
+ default_favicon_url=default_favicon_url,
5659
+ )
5660
+
5661
+
5662
+ @smx.app.route("/admin/features", methods=["GET", "POST"])
5663
+ @admin_required
5664
+ def admin_features():
5665
+ # Defaults from DB (or fall back)
5666
+ def _truthy(v):
5667
+ return str(v or "").strip().lower() in ("1", "true", "yes", "on")
5668
+
5669
+ if request.method == "POST":
5670
+ stream_on = "1" if request.form.get("stream_mode") == "on" else "0"
5671
+ user_files_on = "1" if request.form.get("user_files") == "on" else "0"
5672
+
5673
+ db.set_setting("feature.stream_mode", stream_on)
5674
+ db.set_setting("feature.user_files", user_files_on)
5675
+
5676
+ # Apply immediately (no restart)
5677
+ try:
5678
+ smx._apply_feature_flags_from_db()
5679
+ except Exception:
5680
+ pass
5681
+
5682
+ flash("Settings updated ✓")
5683
+ return redirect(url_for("admin_features"))
5684
+
5685
+ stream_mode = _truthy(db.get_setting("feature.stream_mode", "0"))
5686
+ user_files = _truthy(db.get_setting("feature.user_files", "0"))
5687
+
5688
+ return render_template(
5689
+ "admin_features.html",
5690
+ stream_mode=stream_mode,
5691
+ user_files=user_files,
5692
+ )
5693
+
5694
+
5695
+ @smx.app.route("/admin/delete.json", methods=["POST"])
5696
+ def admin_delete_universal():
5697
+
5698
+ role = (session.get("role") or "").lower()
5699
+ if role != "superadmin":
5700
+ return jsonify(ok=False, error="Not authorized"), 403
5701
+ try:
5702
+ # read resource first; don't require a generic 'id' for all resources
5703
+ resource = (request.form.get("resource") or "").lower()
5704
+ if not resource:
5705
+ return jsonify(ok=False, error="missing resource"), 400
5706
+
5707
+ rid = request.form.get("id") # optional; used by some branches
5708
+
5709
+ if resource == "profile":
5710
+ # profiles use 'profile_name' (or fallback to 'id' if you ever send it that way)
5711
+ prof_name = request.form.get("profile_name") or rid
4921
5712
  if not prof_name:
4922
5713
  return jsonify(ok=False, error="missing profile_name"), 400
4923
5714
 
@@ -5082,47 +5873,56 @@ def setup_routes(smx):
5082
5873
  smx.warning(f"/admin/delete.json error: {e}")
5083
5874
  return jsonify(ok=False, error=str(e)), 500
5084
5875
 
5085
- # Override the generic page renderer to inject a gallery on the "service" page
5876
+
5086
5877
  @smx.app.route('/page/<page_name>')
5087
5878
  def view_page(page_name):
5088
- smx.page = page_name.lower()
5089
- nav_html = _generate_nav()
5090
- content = smx.pages.get(page_name, f"No content found for page '{page_name}'.")
5091
-
5092
- # only on the service page, build a gallery
5093
- media_html = ''
5094
- if page_name.lower() == 'service':
5095
- media_folder = os.path.join(_CLIENT_DIR, 'uploads', 'media')
5096
- if os.path.isdir(media_folder):
5097
- files = sorted(os.listdir(media_folder))
5098
- # wrap each file in an <img> tag (you can special‑case videos if you like)
5099
- thumbs = []
5100
- for fn in files:
5101
- src = url_for('serve_media', filename=fn)
5102
- thumbs.append(f'<img src="{src}" alt="{fn}" style="max-width:150px; margin:5px;"/>')
5103
- if thumbs:
5104
- media_html = f'''
5105
- <section id="media-gallery" style="margin-top:20px;">
5106
- <h3>Media Gallery</h3>
5107
- <div style="display:flex; flex-wrap:wrap; gap:10px;">
5108
- {''.join(thumbs)}
5109
- </div>
5110
- </section>
5111
- '''
5879
+ hero_fix_css = """
5880
+ <style>
5881
+ div[id^="smx-page-"] .hero-overlay{
5882
+ background:linear-gradient(90deg,
5883
+ rgba(2,6,23,.62) 0%,
5884
+ rgba(2,6,23,.40) 42%,
5885
+ rgba(2,6,23,.14) 72%,
5886
+ rgba(2,6,23,.02) 100%
5887
+ ) !important;
5888
+ }
5889
+ @media (max-width: 860px){
5890
+ div[id^="smx-page-"] .hero-overlay{
5891
+ background:linear-gradient(180deg,
5892
+ rgba(2,6,23,.16) 0%,
5893
+ rgba(2,6,23,.55) 70%,
5894
+ rgba(2,6,23,.70) 100%
5895
+ ) !important;
5896
+ }
5897
+ }
5898
+ div[id^="smx-page-"] .hero-panel{
5899
+ background:rgba(2,6,23,.24) !important;
5900
+ backdrop-filter: blur(4px) !important;
5901
+ -webkit-backdrop-filter: blur(4px) !important;
5902
+ }
5903
+ </style>
5904
+ """
5112
5905
 
5113
- view_page_html = f"""
5114
- {head_html()}
5115
- {nav_html}
5116
- <div style=" width:100%; box-sizing:border-box; padding-top:5px;">
5117
- <div style="text-align:center; border:1px solid #ccc;
5118
- border-radius:8px; background-color:#f9f9f9;">
5119
- <div>{content}</div>
5120
- {media_html}
5121
- </div>
5122
- </div>
5123
- {footer_html()}
5124
- """
5125
- return Response(view_page_html, mimetype="text/html")
5906
+ smx.page = page_name.lower()
5907
+ nav_html = _generate_nav()
5908
+ # Always fetch the latest HTML from disk/DB (prevents stale cache across workers)
5909
+ content = db.get_page_html(page_name)
5910
+ if content is None:
5911
+ content = smx.pages.get(page_name, f"No content found for page '{page_name}'.")
5912
+
5913
+ view_page_html = f"""
5914
+ {head_html()}
5915
+ {nav_html}
5916
+ <main style="padding-top:calc(52px + env(safe-area-inset-top)); width:100%; box-sizing:border-box;">
5917
+ {content}
5918
+ </main>
5919
+ {hero_fix_css}
5920
+ {footer_html()}
5921
+ """
5922
+ resp = Response(view_page_html, mimetype="text/html")
5923
+ # Prevent the browser/proxies from keeping an old copy during active editing/publishing
5924
+ resp.headers["Cache-Control"] = "no-store"
5925
+ return resp
5126
5926
 
5127
5927
 
5128
5928
  @smx.app.route('/docs')
@@ -5182,7 +5982,6 @@ def setup_routes(smx):
5182
5982
  html += "</table>"
5183
5983
  return html
5184
5984
 
5185
-
5186
5985
  @smx.app.route("/admin/chunks/edit/<int:chunk_id>", methods=["GET", "POST"])
5187
5986
  def edit_chunk(chunk_id):
5188
5987
  if request.method == "POST":
@@ -5217,78 +6016,212 @@ def setup_routes(smx):
5217
6016
  def edit_page(page_name):
5218
6017
  if request.method == "POST":
5219
6018
  new_page_name = request.form.get("page_name", "").strip()
5220
- new_content = request.form.get("page_content", "").strip()
6019
+ # Keep page_content formatting exactly as typed
6020
+ new_content = request.form.get("page_content", "")
6021
+
5221
6022
  if page_name in smx.pages and new_page_name:
5222
6023
  db.update_page(page_name, new_page_name, new_content)
6024
+ smx.pages = db.get_pages()
5223
6025
  return redirect(url_for("admin_panel"))
5224
- # Load the full content for the page to be edited.
5225
- content = smx.pages.get(page_name, "")
5226
- return render_template_string("""
5227
- <!DOCTYPE html>
5228
- <html>
5229
- <head>
5230
- <meta charset="UTF-8">
5231
- <title>Edit Page - {{ page_name }}</title>
5232
- <style>
5233
- body {
5234
- background: #f4f7f9;
5235
- padding: 20px;
5236
- }
5237
- .editor {
5238
- max-width: 800px;
5239
- margin: 0 auto;
5240
- background: #fff;
5241
- padding: 20px;
5242
- border-radius: 8px;
5243
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
5244
- }
5245
- input, textarea {
5246
- width: 100%;
5247
- margin: 10px 0;
5248
- padding: 10px;
5249
- border: 1px solid #ccc;
5250
- border-radius: 4px;
5251
- }
5252
- button {
5253
- padding: 10px 20px;
5254
- background: #007acc;
5255
- border: none;
5256
- color: #fff;
5257
- border-radius: 4px;
5258
- cursor: pointer;
5259
- }
5260
- button:hover {
5261
- background: #005fa3;
5262
- }
5263
- a.button {
5264
- padding: 10px 20px;
5265
- background: #aaa;
5266
- border: none;
5267
- color: #fff;
5268
- border-radius: 4px;
5269
- text-decoration: none;
5270
- }
5271
- a.button:hover {
5272
- background: #888;
5273
- }
5274
- </style>
5275
- </head>
5276
- <body>
5277
- <div class="editor">
5278
- <h1>Edit Page - {{ page_name }}</h1>
5279
- <form method="post">
5280
- <input type="text" name="page_name" value="{{ page_name }}" required>
5281
- <textarea name="page_content" rows="20">{{ content }}</textarea>
5282
- <div style="margin-top:15px;">
5283
- <button type="submit">Update Page</button>
5284
- <a class="button" href="{{ url_for('admin_panel') }}">Cancel</a>
5285
- </div>
5286
- </form>
5287
- </div>
5288
- </body>
5289
- </html>
5290
- """, page_name=page_name, content=content)
6026
+
6027
+ content = db.get_page_html(page_name) or ""
6028
+
6029
+ # NEW: builder layout json (stored separately)
6030
+ layout_row = getattr(db, "get_page_layout", None)
6031
+ layout_json = None
6032
+ if callable(layout_row):
6033
+ try:
6034
+ row = db.get_page_layout(page_name)
6035
+ layout_json = (row or {}).get("layout_json")
6036
+ except Exception:
6037
+ layout_json = None
6038
+ published_as = request.args.get("published_as")
6039
+ return render_template(
6040
+ "edit_page.html",
6041
+ page_name=page_name,
6042
+ content=content,
6043
+ layout_json=layout_json,
6044
+ published_as=published_as,
6045
+ )
5291
6046
 
6047
+
6048
+ # ────────────────────────────────────────────────────
6049
+ # PIXABAY
6050
+ # ────────────────────────────────────────────────────
6051
+ @smx.app.route("/admin/pixabay/search.json", methods=["GET"])
6052
+ def admin_pixabay_search():
6053
+ role = (session.get("role") or "").lower()
6054
+ if role not in ("admin", "superadmin"):
6055
+ return jsonify({"error": "forbidden"}), 403
6056
+
6057
+ q = (request.args.get("q") or "").strip()
6058
+ orientation = (request.args.get("orientation") or "horizontal").strip().lower()
6059
+ image_type = (request.args.get("image_type") or "photo").strip().lower()
6060
+
6061
+ api_key = None
6062
+ try:
6063
+ api_key = db.get_secret("PIXABAY_API_KEY")
6064
+ except Exception:
6065
+ api_key = None
6066
+
6067
+ if not api_key:
6068
+ return jsonify({"error": "Missing PIXABAY_API_KEY. Add it in Admin → Manage secretes."}), 400
6069
+
6070
+ try:
6071
+ from syntaxmatrix.media.media_pixabay import pixabay_search
6072
+ hits = pixabay_search(
6073
+ api_key=api_key,
6074
+ query=q,
6075
+ image_type=image_type,
6076
+ orientation=orientation,
6077
+ per_page=24,
6078
+ safesearch=True,
6079
+ editors_choice=False,
6080
+ min_width=960,
6081
+ )
6082
+ payload = []
6083
+ for h in hits:
6084
+ payload.append({
6085
+ "id": h.id,
6086
+ "page_url": h.page_url,
6087
+ "preview_url": h.preview_url,
6088
+ "large_image_url": h.large_image_url,
6089
+ "webformat_url": h.webformat_url,
6090
+ "width": h.width,
6091
+ "height": h.height,
6092
+ "tags": h.tags,
6093
+ "user": h.user,
6094
+ "type": h.image_type
6095
+ })
6096
+ return jsonify({"items": payload}), 200
6097
+
6098
+ except Exception as e:
6099
+ return jsonify({"error": str(e)}), 500
6100
+
6101
+ @smx.app.route("/admin/pixabay/import.json", methods=["POST"])
6102
+ def admin_pixabay_import():
6103
+ role = (session.get("role") or "").lower()
6104
+ if role not in ("admin", "superadmin"):
6105
+ return jsonify({"error": "forbidden"}), 403
6106
+
6107
+ api_key = None
6108
+ try:
6109
+ api_key = db.get_secret("PIXABAY_API_KEY")
6110
+ except Exception:
6111
+ api_key = None
6112
+
6113
+ if not api_key:
6114
+ return jsonify({"error": "Missing PIXABAY_API_KEY. Add it in Admin → Manage secretes."}), 400
6115
+
6116
+ payload = request.get_json(force=True) or {}
6117
+ pixabay_id = payload.get("id")
6118
+ if not pixabay_id:
6119
+ return jsonify({"error": "Missing id"}), 400
6120
+
6121
+ min_width = int(payload.get("min_width") or 0)
6122
+ min_width = max(0, min(3000, min_width))
6123
+
6124
+ try:
6125
+ import requests
6126
+ from syntaxmatrix.media.media_pixabay import PixabayHit, import_pixabay_hit
6127
+
6128
+ # Look up the hit by ID from Pixabay API (prevents client tampering)
6129
+ r = requests.get(
6130
+ "https://pixabay.com/api/",
6131
+ params={"key": api_key, "id": str(pixabay_id)},
6132
+ timeout=15
6133
+ )
6134
+ r.raise_for_status()
6135
+ data = r.json() or {}
6136
+ hits = data.get("hits") or []
6137
+ if not hits:
6138
+ return jsonify({"error": "Pixabay image not found"}), 404
6139
+
6140
+ h = hits[0]
6141
+ hit = PixabayHit(
6142
+ id=int(h.get("id")),
6143
+ page_url=str(h.get("pageURL") or ""),
6144
+ tags=str(h.get("tags") or ""),
6145
+ user=str(h.get("user") or ""),
6146
+ preview_url=str(h.get("previewURL") or ""),
6147
+ webformat_url=str(h.get("webformatURL") or ""),
6148
+ large_image_url=str(h.get("largeImageURL") or ""),
6149
+ width=int(h.get("imageWidth") or 0),
6150
+ height=int(h.get("imageHeight") or 0),
6151
+ image_type=str(h.get("type") or "photo"),
6152
+ )
6153
+
6154
+ # Paths
6155
+ media_dir = os.path.join(_CLIENT_DIR, "uploads", "media")
6156
+ imported_dir = os.path.join(media_dir, "images", "imported")
6157
+ thumbs_dir = os.path.join(media_dir, "images", "thumbs")
6158
+ os.makedirs(imported_dir, exist_ok=True)
6159
+ os.makedirs(thumbs_dir, exist_ok=True)
6160
+
6161
+ # Download-once guard: if already imported, reuse local file
6162
+ existing_jpg = os.path.join(imported_dir, f"pixabay-{hit.id}.jpg")
6163
+ existing_png = os.path.join(imported_dir, f"pixabay-{hit.id}.png")
6164
+
6165
+ if os.path.exists(existing_jpg) or os.path.exists(existing_png):
6166
+ existing_abs = existing_png if os.path.exists(existing_png) else existing_jpg
6167
+ rel_path = os.path.relpath(existing_abs, media_dir).replace("\\", "/")
6168
+ return jsonify({
6169
+ "rel_path": rel_path,
6170
+ "url": url_for("serve_media", filename=rel_path),
6171
+ "thumb_url": None,
6172
+ "source_url": hit.page_url,
6173
+ "author": hit.user,
6174
+ "tags": hit.tags,
6175
+ }), 200
6176
+
6177
+ meta = import_pixabay_hit(
6178
+ hit,
6179
+ media_images_dir=imported_dir,
6180
+ thumbs_dir=thumbs_dir,
6181
+ max_width=1920,
6182
+ thumb_width=800,
6183
+ min_width=min_width
6184
+ )
6185
+
6186
+ # Convert absolute paths to rel paths + URLs
6187
+ rel_path = os.path.relpath(meta["file_path"], media_dir).replace("\\", "/")
6188
+ thumb_rel = None
6189
+ if meta.get("thumb_path"):
6190
+ thumb_rel = os.path.relpath(meta["thumb_path"], media_dir).replace("\\", "/")
6191
+
6192
+ # Register in DB (for local-first & Media sources)
6193
+ try:
6194
+ db.upsert_media_asset(
6195
+ rel_path=rel_path,
6196
+ kind="image",
6197
+ thumb_path=thumb_rel,
6198
+ sha256=meta.get("sha256"),
6199
+ dhash=meta.get("dhash"),
6200
+ width=int(meta.get("width") or 0),
6201
+ height=int(meta.get("height") or 0),
6202
+ mime=meta.get("mime"),
6203
+ source="pixabay",
6204
+ source_url=meta.get("source_url"),
6205
+ author=meta.get("author"),
6206
+ licence="Pixabay Content Licence",
6207
+ tags=meta.get("tags"),
6208
+ )
6209
+ except Exception:
6210
+ pass
6211
+
6212
+ return jsonify({
6213
+ "rel_path": rel_path,
6214
+ "url": url_for("serve_media", filename=rel_path),
6215
+ "thumb_url": url_for("serve_media", filename=thumb_rel) if thumb_rel else None,
6216
+ "source_url": meta.get("source_url"),
6217
+ "author": meta.get("author"),
6218
+ "tags": meta.get("tags"),
6219
+ }), 200
6220
+
6221
+ except Exception as e:
6222
+ return jsonify({"error": str(e)}), 500
6223
+
6224
+
5292
6225
  # ────────────────────────────────────────────────────
5293
6226
  # ACCOUNTS
5294
6227
  # ────────────────────────────────────────────────────
@@ -5428,26 +6361,240 @@ def setup_routes(smx):
5428
6361
  return any(r in ("admin", "superadmin") for r in roles if r)
5429
6362
  return dict(can_see_admin=can_see_admin)
5430
6363
 
6364
+
6365
+ def _is_admin_request() -> bool:
6366
+ r = (session.get("role") or "").lower()
6367
+ if r in ("admin", "superadmin"):
6368
+ return True
6369
+
6370
+ # Fallback to Flask-Login user roles (matches your inject_role_helpers logic)
6371
+ if not getattr(current_user, "is_authenticated", False):
6372
+ return False
6373
+
6374
+ roles = getattr(current_user, "roles", None)
6375
+ if roles is None:
6376
+ rr = getattr(current_user, "role", None)
6377
+ roles = [rr] if rr else []
6378
+
6379
+ return any((str(x or "")).lower() in ("admin", "superadmin") for x in roles)
6380
+
6381
+
6382
+ @smx.app.route("/admin/page_layouts/<page_name>", methods=["GET", "POST"])
6383
+ def page_layouts_api(page_name):
6384
+ if not _is_admin_request():
6385
+ return jsonify({"error": "forbidden"}), 403
6386
+
6387
+ if request.method == "GET":
6388
+ try:
6389
+ row = db.get_page_layout(page_name) or {}
6390
+ return jsonify(row), 200
6391
+ except Exception as e:
6392
+ return jsonify({"error": str(e)}), 500
6393
+
6394
+ # POST: save layout json
6395
+ from syntaxmatrix.page_layout_contract import normalise_layout, validate_layout
6396
+ payload = request.get_json(force=True) or {}
6397
+ payload = normalise_layout(payload, mode="draft")
6398
+
6399
+ issues = validate_layout(payload)
6400
+ errors = [i.to_dict() for i in issues if i.level == "error"]
6401
+ warnings = [i.to_dict() for i in issues if i.level == "warning"]
6402
+
6403
+ if errors:
6404
+ return jsonify({"error": "invalid layout", "issues": errors, "warnings": warnings}), 400
6405
+
6406
+ layout_json = json.dumps(payload, ensure_ascii=False)
6407
+ db.upsert_page_layout(page_name, layout_json)
6408
+ return jsonify({"ok": True, "warnings": warnings}), 200
6409
+
6410
+
6411
+ @smx.app.route("/admin/page_layouts/<page_name>/publish", methods=["POST"])
6412
+ def publish_layout_patch_only(page_name):
6413
+
6414
+ role = (session.get("role") or "").lower()
6415
+ if role not in ("admin", "superadmin"):
6416
+ return jsonify({"error": "forbidden"}), 403
6417
+
6418
+ try:
6419
+ # Load layout (prefer request body; fallback to DB)
6420
+ payload = request.get_json(silent=True) or {}
6421
+ if not (isinstance(payload, dict) and isinstance(payload.get("sections"), list)):
6422
+ row = db.get_page_layout(page_name) or {}
6423
+ raw = (row or {}).get("layout_json") or ""
6424
+ payload = json.loads(raw) if raw else {}
6425
+
6426
+ if not (isinstance(payload, dict) and isinstance(payload.get("sections"), list)):
6427
+ return jsonify({"error": "no layout to publish"}), 400
6428
+
6429
+ # Always patch the latest HTML on disk/DB (avoids stale smx.pages in other workers)
6430
+ existing_html = db.get_page_html(page_name) or ""
6431
+ if not existing_html:
6432
+ # Fallback only (older behaviour)
6433
+ if not isinstance(smx.pages, dict):
6434
+ smx.pages = db.get_pages()
6435
+ page_key = (page_name or "").strip()
6436
+ existing_html = smx.pages.get(page_key) or smx.pages.get(page_key.lower()) or ""
6437
+
6438
+ # Keep a copy of what was originally stored so we can correctly detect changes
6439
+ original_html = existing_html
6440
+
6441
+ # NEW: ensure any newly-added layout sections exist in the stored HTML
6442
+ # so validate_compiled_html won't reject the publish.
6443
+ existing_html, inserted_sections = ensure_sections_exist(
6444
+ existing_html,
6445
+ payload,
6446
+ page_slug=page_name
6447
+ )
6448
+
6449
+ if not existing_html:
6450
+ return jsonify({"error": "page html not found"}), 404
6451
+
6452
+ payload = normalise_layout(payload, mode="prod")
6453
+
6454
+ issues = validate_layout(payload)
6455
+ errors = [i.to_dict() for i in issues if i.level == "error"]
6456
+ warnings = [i.to_dict() for i in issues if i.level == "warning"]
6457
+
6458
+ if errors:
6459
+ return jsonify({"error": "invalid layout", "issues": errors, "warnings": warnings}), 400
6460
+
6461
+ # Optional but very useful: validate current HTML has the anchors we need
6462
+ html_issues = validate_compiled_html(existing_html, payload)
6463
+ html_errors = [i.to_dict() for i in html_issues if i.level == "error"]
6464
+ html_warnings = [i.to_dict() for i in html_issues if i.level == "warning"]
6465
+
6466
+ if html_errors:
6467
+ return jsonify({"error": "html not compatible with patching", "issues": html_errors, "warnings": html_warnings}), 400
6468
+
6469
+ updated_html, stats = patch_page_publish(existing_html, payload, page_slug=page_name)
6470
+
6471
+ # If nothing changed, still return ok
6472
+ if updated_html == original_html:
6473
+ return jsonify({"ok": True, "mode": "noop", "stats": stats}), 200
6474
+
6475
+ # Persist patched HTML
6476
+ db.update_page(page_name, page_name, updated_html)
6477
+ smx.pages = db.get_pages()
6478
+
6479
+ return jsonify({"ok": True, "mode": "patched", "stats": stats}), 200
6480
+
6481
+ except Exception as e:
6482
+ smx.warning(f"publish_layout_patch_only error: {e}")
6483
+ return jsonify({"error": str(e)}), 500
6484
+
6485
+
6486
+ @smx.app.route("/admin/page_layouts/<page_name>/compile", methods=["POST"])
6487
+ def compile_page_layout(page_name):
6488
+ role = (session.get("role") or "").lower()
6489
+ if role not in ("admin", "superadmin"):
6490
+ return jsonify({"error": "forbidden"}), 403
6491
+
6492
+ try:
6493
+ payload = request.get_json(force=True) or {}
6494
+ html_doc = compile_layout_to_html(payload, page_slug=page_name)
6495
+ return jsonify({"html": html_doc}), 200
6496
+ except Exception as e:
6497
+ return jsonify({"error": str(e)}), 500
6498
+
6499
+
6500
+ @smx.app.route("/admin/media/list.json", methods=["GET"])
6501
+ def list_media_json():
6502
+ role = (session.get("role") or "").lower()
6503
+ if role not in ("admin", "superadmin"):
6504
+ return jsonify({"error": "forbidden"}), 403
6505
+
6506
+ media_dir = os.path.join(_CLIENT_DIR, "uploads", "media")
6507
+ items = []
6508
+ img_ext = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
6509
+ vid_ext = {".mp4", ".webm", ".mov", ".m4v"}
6510
+
6511
+ for root, _, files in os.walk(media_dir):
6512
+ for fn in files:
6513
+ abs_path = os.path.join(root, fn)
6514
+ rel = os.path.relpath(abs_path, media_dir).replace("\\", "/")
6515
+ ext = os.path.splitext(fn.lower())[1]
6516
+ kind = "other"
6517
+ if ext in img_ext:
6518
+ kind = "image"
6519
+ elif ext in vid_ext:
6520
+ kind = "video"
6521
+ items.append({
6522
+ "name": fn,
6523
+ "path": rel,
6524
+ "url": url_for("serve_media", filename=rel),
6525
+ "kind": kind
6526
+ })
6527
+
6528
+ items.sort(key=lambda x: x["path"])
6529
+ return jsonify({"items": items}), 200
6530
+
6531
+ # # Example usage in your existing routes
6532
+ # @smx.app.route("/admin/generate_image", methods=["POST"])
6533
+ # def generate_image_route():
6534
+ # prompt = request.json.get("prompt", "").strip()
6535
+ # kind = request.json.get("kind", "image")
6536
+ # count = int(request.json.get("count", 1))
6537
+ # out_dir = os.path.join(MEDIA_IMAGES_GENERATED_ICONS if kind == "icon" else MEDIA_IMAGES_GENERATED)
6538
+
6539
+ # if not prompt:
6540
+ # return jsonify({"error": "Missing prompt"}), 400
6541
+
6542
+ # vision_profile = smx.get_image_generator_profile()
6543
+
6544
+ # # Call the agent's generate_image function
6545
+ # try:
6546
+ # result = image_generator_agent(prompt, vision_profile, out_dir, count)
6547
+ # return jsonify({"items": result}), 200
6548
+ # except Exception as e:
6549
+ # return jsonify({"error": f"Image generation failed: {str(e)}"}), 500
6550
+
6551
+
5431
6552
  # --- UPLOAD MEDIA --------------------------------------
5432
6553
  @smx.app.route("/admin/upload_media", methods=["POST"])
5433
- def upload_media():
5434
- # Retrieve uploaded media files (images, videos, etc.).
6554
+ def upload_media():
5435
6555
  uploaded_files = request.files.getlist("media_files")
5436
6556
  file_paths = []
6557
+
5437
6558
  for file in uploaded_files:
5438
- if file.filename:
5439
- filepath = os.path.join(MEDIA_FOLDER, file.filename)
6559
+ if not file or not file.filename:
6560
+ continue
6561
+
6562
+ fn = file.filename
6563
+ ext = os.path.splitext(fn.lower())[1]
6564
+ img_ext = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
6565
+ vid_ext = {".mp4", ".webm", ".mov", ".m4v"}
6566
+
6567
+ if ext in img_ext:
6568
+ filepath = os.path.join(MEDIA_IMAGES_UPLOADED, fn)
6569
+ file.save(filepath)
6570
+ rel = os.path.relpath(filepath, MEDIA_FOLDER).replace("\\", "/")
6571
+ file_paths.append(f"/uploads/media/{rel}")
6572
+ elif ext in vid_ext:
6573
+ filepath = os.path.join(MEDIA_VIDEOS_UPLOADED, fn)
5440
6574
  file.save(filepath)
5441
- # This path can be copied by the developer. Adjust if you have a web server serving these files.
5442
- file_paths.append(f"/uploads/media/{file.filename}")
6575
+ rel = os.path.relpath(filepath, MEDIA_FOLDER).replace("\\", "/")
6576
+ file_paths.append(f"/uploads/media/{rel}")
6577
+ else:
6578
+ filepath = os.path.join(MEDIA_FOLDER, fn)
6579
+ file.save(filepath)
6580
+ file_paths.append(f"/uploads/media/{fn}")
6581
+
5443
6582
  return jsonify({"file_paths": file_paths})
5444
6583
 
6584
+
5445
6585
  # Serve the raw media files
5446
6586
  @smx.app.route('/uploads/media/<path:filename>')
5447
6587
  def serve_media(filename):
5448
6588
  media_dir = os.path.join(_CLIENT_DIR, 'uploads', 'media')
5449
6589
  return send_from_directory(media_dir, filename)
5450
6590
 
6591
+
6592
+ @smx.app.route("/branding/<path:filename>")
6593
+ def serve_branding(filename):
6594
+ branding_dir = os.path.join(_CLIENT_DIR, "branding")
6595
+ return send_from_directory(branding_dir, filename)
6596
+
6597
+
5451
6598
  # ────────────────────────────────────────────────────────────────────────────────────────
5452
6599
  # DASHBOARD
5453
6600
  # ────────────────────────────────────────────────────────────────────────────────────────
@@ -5459,104 +6606,7 @@ def setup_routes(smx):
5459
6606
 
5460
6607
  max_rows = 5000
5461
6608
  max_cols = 80
5462
-
5463
- def _smx_repair_python_cell(py_code: str) -> str:
5464
-
5465
- _CELL_REPAIR_RULES = """
5466
- You are an experienced Python code reviewer
5467
- Fix the Python cell to satisfy:
5468
- - Single valid cell; imports at the top.
5469
- - Do not import or invoke or use 'python-dotenv' or 'dotenv' because it's not needed.
5470
- - No top-level statements between if/elif/else branches.
5471
- - Regression must use either sklearn with train_test_split (then X_test exists) and R^2/MAE/RMSE,
5472
- or statsmodels OLS. No accuracy_score in regression.
5473
- - Keep all plotting + savefig + BytesIO + display inside the branch that created the figure.
5474
- - Return ONLY the corrected cell.
5475
- """
5476
- code = textwrap.dedent(py_code or "").strip()
5477
- needs_fix = False
5478
- if re.search(r"\baccuracy_score\b", code) and re.search(r"\bLinearRegression\b|\bOLS\b", code):
5479
- needs_fix = True
5480
- if re.search(r"\bX_test\b", code) and not re.search(r"\bX_test\s*=", code):
5481
- needs_fix = True
5482
- try:
5483
- ast.parse(code)
5484
- except SyntaxError:
5485
- needs_fix = True
5486
- if not needs_fix:
5487
- return code
5488
-
5489
- _prompt = f"```python\n{code}\n```"
5490
-
5491
- prof = _prof.get_profile("classification") or _prof.get_profile("admin")
5492
- if not prof:
5493
- return code
5494
-
5495
- prof["client"] = _prof.get_client(prof)
5496
- _client = prof["client"]
5497
- _model = prof["model"]
5498
- _provider = prof["provider"].lower()
5499
-
5500
- #1 Google
5501
- if _provider == "google":
5502
- from google.genai import types
5503
-
5504
- fixed = _client.models.generate_content(
5505
- model=_model,
5506
- contents=_prompt,
5507
- config=types.GenerateContentConfig(
5508
- system_instruction=_CELL_REPAIR_RULES,
5509
- temperature=0.8,
5510
- max_output_tokens=1024,
5511
- ),
5512
- )
5513
-
5514
- #2 Openai
5515
- elif _provider == "openai" and _model in GPT_MODELS_LATEST:
5516
-
5517
- args = set_args(
5518
- model=_model,
5519
- instructions=_CELL_REPAIR_RULES,
5520
- input=[{"role": "user", "content": _prompt}],
5521
- previous_id=None,
5522
- store=False,
5523
- reasoning_effort="medium",
5524
- verbosity="medium",
5525
- )
5526
- fixed = _out(_client.responses.create(**args))
5527
-
5528
- # Anthropic
5529
- elif _provider == "anthropic":
5530
-
5531
- fixed = _client.messages.create(
5532
- model=_model,
5533
- max_tokens=1024,
5534
- system=_CELL_REPAIR_RULES,
5535
- messages=[{"role": "user", "content":_prompt}],
5536
- stream=False,
5537
- )
5538
-
5539
- # OpenAI SDK
5540
- else:
5541
- fixed = _client.chat.completions.create(
5542
- model=_model,
5543
- messages=[
5544
- {"role": "system", "content":_CELL_REPAIR_RULES},
5545
- {"role": "user", "content":_prompt},
5546
- ],
5547
- max_tokens=1024,
5548
- )
5549
-
5550
- fixed_txt = clean_llm_code(fixed)
5551
-
5552
- try:
5553
- # Only accept the repaired cell if it's valid Python
5554
- ast.parse(fixed_txt)
5555
- return fixed_txt
5556
- except Exception:
5557
- # If the repaired version is still broken, fall back to the original code
5558
- return code
5559
-
6609
+
5560
6610
  section = request.args.get("section", "explore")
5561
6611
  datasets = [f for f in os.listdir(DATA_FOLDER) if f.lower().endswith(".csv")]
5562
6612
  selected_dataset = request.form.get("dataset") or request.args.get("dataset")
@@ -5593,6 +6643,8 @@ def setup_routes(smx):
5593
6643
  eda_df = df
5594
6644
  llm_usage = None
5595
6645
 
6646
+ TOKENS = {}
6647
+
5596
6648
  if request.method == "POST" and "askai_question" in request.form:
5597
6649
  askai_question = request.form["askai_question"].strip()
5598
6650
  if df is not None:
@@ -5608,55 +6660,95 @@ def setup_routes(smx):
5608
6660
  columns_summary = ", ".join(df.columns.tolist())
5609
6661
  dataset_context = f"columns: {columns_summary}"
5610
6662
  dataset_profile = f"modality: tabular; columns: {columns_summary}"
5611
-
5612
- refined_question = refine_question_agent(askai_question, dataset_context)
5613
- tags = classify_ml_job_agent(refined_question, dataset_profile)
5614
-
5615
- ai_code = smx.ai_generate_code(refined_question, tags, df)
5616
- llm_usage = smx.get_last_llm_usage()
5617
- ai_code = auto_inject_template(ai_code, tags, df)
5618
-
5619
- # --- 1) Strip dotenv ASAP (kill imports, %magics, !pip) ---
5620
- ctx = {
5621
- "question": refined_question,
5622
- "df_columns": list(df.columns),
5623
- }
5624
- ai_code = ToolRunner(EARLY_SANITIZERS).run(ai_code, ctx) # dotenv first
6663
+
6664
+ # ai_code = smx.ai_generate_code(refined_question, tags, df)
6665
+ # llm_usage = smx.get_last_llm_usage()
6666
+ # ai_code = auto_inject_template(ai_code, tags, df)
6667
+
6668
+ # # --- 1) Strip dotenv ASAP (kill imports, %magics, !pip) ---
6669
+ # ctx = {
6670
+ # "question": refined_question,
6671
+ # "df_columns": list(df.columns),
6672
+ # }
6673
+ # ai_code = ToolRunner(EARLY_SANITIZERS).run(ai_code, ctx) # dotenv first
5625
6674
 
5626
- # --- 2) Domain/Plotting patches ---
5627
- ai_code = fix_scatter_and_summary(ai_code)
5628
- ai_code = fix_importance_groupby(ai_code)
5629
- ai_code = inject_auto_preprocessing(ai_code)
5630
- ai_code = patch_plot_code(ai_code, df, refined_question)
5631
- ai_code = ensure_matplotlib_title(ai_code)
5632
- ai_code = patch_pie_chart(ai_code, df, refined_question)
5633
- ai_code = patch_pairplot(ai_code, df)
5634
- ai_code = fix_seaborn_boxplot_nameerror(ai_code)
5635
- ai_code = fix_seaborn_barplot_nameerror(ai_code)
5636
- ai_code = get_plotting_imports(ai_code)
5637
- ai_code = patch_prefix_seaborn_calls(ai_code)
5638
- ai_code = patch_fix_sentinel_plot_calls(ai_code)
5639
- ai_code = patch_ensure_seaborn_import(ai_code)
5640
- ai_code = patch_rmse_calls(ai_code)
5641
- ai_code = patch_fix_seaborn_palette_calls(ai_code)
5642
- ai_code = patch_quiet_specific_warnings(ai_code)
5643
- ai_code = clean_llm_code(ai_code)
5644
- ai_code = ensure_image_output(ai_code)
5645
- ai_code = ensure_accuracy_block(ai_code)
5646
- ai_code = ensure_output(ai_code)
5647
- ai_code = fix_plain_prints(ai_code)
5648
- ai_code = fix_print_html(ai_code)
5649
- ai_code = fix_to_datetime_errors(ai_code)
6675
+ # # --- 2) Domain/Plotting patches ---
6676
+ # ai_code = fix_scatter_and_summary(ai_code)
6677
+ # ai_code = fix_importance_groupby(ai_code)
6678
+ # ai_code = inject_auto_preprocessing(ai_code)
6679
+ # ai_code = patch_plot_code(ai_code, df, refined_question)
6680
+ # ai_code = ensure_matplotlib_title(ai_code)
6681
+ # ai_code = patch_pie_chart(ai_code, df, refined_question)
6682
+ # ai_code = patch_pairplot(ai_code, df)
6683
+ # ai_code = fix_seaborn_boxplot_nameerror(ai_code)
6684
+ # ai_code = fix_seaborn_barplot_nameerror(ai_code)
6685
+ # ai_code = get_plotting_imports(ai_code)
6686
+ # ai_code = patch_prefix_seaborn_calls(ai_code)
6687
+ # ai_code = patch_fix_sentinel_plot_calls(ai_code)
6688
+ # ai_code = patch_ensure_seaborn_import(ai_code)
6689
+ # ai_code = patch_rmse_calls(ai_code)
6690
+ # ai_code = patch_fix_seaborn_palette_calls(ai_code)
6691
+ # ai_code = patch_quiet_specific_warnings(ai_code)
6692
+ # ai_code = clean_llm_code(ai_code)
6693
+ # ai_code = ensure_image_output(ai_code)
6694
+ # ai_code = ensure_accuracy_block(ai_code)
6695
+ # ai_code = ensure_output(ai_code)
6696
+ # ai_code = fix_plain_prints(ai_code)
6697
+ # ai_code = fix_print_html(ai_code)
6698
+ # ai_code = fix_to_datetime_errors(ai_code)
5650
6699
 
5651
- # --- 3-4) Global syntax/data fixers (must run AFTER patches, BEFORE final repair) ---
5652
- ai_code = ToolRunner(SYNTAX_AND_REPAIR).run(ai_code, ctx)
5653
-
5654
- # # --- 4) Final catch-all repair (run LAST) ---
5655
- ai_code = _smx_repair_python_cell(ai_code)
5656
- ai_code = harden_ai_code(ai_code)
5657
- ai_code = drop_bad_classification_metrics(ai_code, df)
5658
- ai_code = patch_fix_sentinel_plot_calls(ai_code)
5659
-
6700
+ # # --- 3-4) Global syntax/data fixers (must run AFTER patches, BEFORE final repair) ---
6701
+ # ai_code = ToolRunner(SYNTAX_AND_REPAIR).run(ai_code, ctx)
6702
+
6703
+ # # # --- 4) Final catch-all repair (run LAST) ---
6704
+ # ai_code = smx.repair_python_cell(ai_code)
6705
+ # ai_code = harden_ai_code(ai_code)
6706
+ # ai_code = drop_bad_classification_metrics(ai_code, df)
6707
+ # ai_code = patch_fix_sentinel_plot_calls(ai_code)
6708
+
6709
+ from syntaxmatrix.agentic import agents_orchestrer
6710
+ orch = agents_orchestrer.OrchestrateMLSystem(askai_question, cleaned_path)
6711
+ result = orch.operator_agent()
6712
+
6713
+ refined_question = result["specs_cot"]
6714
+
6715
+ compatibility = context_compatibility(askai_question, dataset_context)
6716
+ if compatibility.lower() == "incompatible" or compatibility.lower() == "mismatch":
6717
+ return ("""
6718
+ <div style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center;">
6719
+ <h1 style="margin: 0 0 10px 0;">Oops: Context mismatch</h1>
6720
+ <p style="margin: 0;">Please, upload the proper dataset for solution to your query.</p>
6721
+ <br>
6722
+ <a class='button' href='/dashboard' style='text-decoration:none;'>Return</a>
6723
+ </div>
6724
+ """)
6725
+ else:
6726
+ tags = classify_ml_job_agent(refined_question, dataset_profile)
6727
+
6728
+ TOKENS["Refiner"] = [
6729
+ result['token_usage'].get('Refiner')['usage'].get('provider'),
6730
+ result['token_usage'].get('Refiner')['usage'].get('model'),
6731
+ result['token_usage'].get('Refiner')['usage'].get('input_tokens'),
6732
+ result['token_usage'].get('Refiner')['usage'].get('output_tokens'),
6733
+ result['token_usage'].get('Refiner')['usage'].get('total_tokens'),
6734
+ ]
6735
+ TOKENS["Coder"] = [
6736
+ result['token_usage'].get('Coder')['usage'].get('provider'),
6737
+ result['token_usage'].get('Coder')['usage'].get('model'),
6738
+ result['token_usage'].get('Coder')['usage'].get('input_tokens'),
6739
+ result['token_usage'].get('Coder')['usage'].get('output_tokens'),
6740
+ result['token_usage'].get('Coder')['usage'].get('total_tokens'),
6741
+ ]
6742
+
6743
+ ai_code = result.get("python_code", "")
6744
+ # ai_code = patch_quiet_specific_warnings(ai_code)
6745
+ # ai_code = fix_print_html(ai_code)
6746
+ # ai_code = fix_plain_prints(ai_code)
6747
+ # ai_code = harden_ai_code(ai_code)
6748
+ # ai_code = ensure_image_output(ai_code)
6749
+ # ai_code = ensure_accuracy_block(ai_code)
6750
+ # ai_code = ensure_output(ai_code)
6751
+
5660
6752
  # Always make sure 'df' is in the kernel before running user code
5661
6753
  df_init_code = (
5662
6754
  f"import pandas as pd\n"
@@ -5892,14 +6984,34 @@ def setup_routes(smx):
5892
6984
 
5893
6985
  # 3) Data Preview
5894
6986
  preview_cols = df.columns
5895
- preview_df = _coerce_intlike_for_display(df[preview_cols].head(8))
6987
+
6988
+ head_df = _coerce_intlike_for_display(df[preview_cols].head(8))
5896
6989
  data_cells.append({
5897
- "title": "Data Preview",
5898
- "output": Markup(datatable_box(preview_df)),
6990
+ "title": "Dataset Head",
6991
+ "output": Markup(datatable_box(head_df)),
5899
6992
  "code": f"df[{list(preview_cols)}].head(8)",
5900
6993
  "span": "eda-col-6"
5901
6994
  })
5902
6995
 
6996
+ # Calculate the start index for the middle 8 rows
6997
+ n_rows = len(df)
6998
+ start_index = max(0, floor(n_rows / 2) - 4)
6999
+ middle_df = df.iloc[start_index : start_index + 8]
7000
+ data_cells.append({
7001
+ "title": "Dataset Middle (8 Rows)",
7002
+ "output": Markup(datatable_box(middle_df[list(preview_cols)])),
7003
+ "code": f"n = len(df)\nstart_index = max(0, floor(n / 2) - 4)\ndf.iloc[start_index : start_index + 8][{list(preview_cols)}]",
7004
+ "span": "eda-col-6"
7005
+ })
7006
+
7007
+ tail_df = _coerce_intlike_for_display(df[preview_cols].tail(8))
7008
+ data_cells.append({
7009
+ "title": "Dataset Tail",
7010
+ "output": Markup(datatable_box(tail_df)),
7011
+ "code": f"df[{list(preview_cols)}].tail(8)",
7012
+ "span": "eda-col-6"
7013
+ })
7014
+
5903
7015
  # 4) Summary Statistics
5904
7016
  summary_cols = df.columns
5905
7017
  summary_df = _coerce_intlike_for_display(df[summary_cols].describe())
@@ -6253,7 +7365,7 @@ def setup_routes(smx):
6253
7365
  "})\\n"
6254
7366
  "missing_df[missing_df['Missing Values'] > 0]"
6255
7367
  ),
6256
- "span":"eda-col-4"
7368
+ "span":"eda-col-3"
6257
7369
  })
6258
7370
 
6259
7371
  # 9) Missingness (Top 20) – Plotly bar chart
@@ -6492,7 +7604,7 @@ def setup_routes(smx):
6492
7604
  "vc = s.value_counts(dropna=False)\n"
6493
7605
  "top_k = 8 # Top-8 + Other (+ Missing)\n"
6494
7606
  ),
6495
- "span": "eda-col-4"
7607
+ "span": "eda-col-5"
6496
7608
  })
6497
7609
  else:
6498
7610
  data_cells.append({
@@ -6513,8 +7625,8 @@ def setup_routes(smx):
6513
7625
  cell["highlighted_code"] = Markup(_pygmentize(cell["code"]))
6514
7626
 
6515
7627
  highlighted_ai_code = _pygmentize(ai_code)
6516
- tasks = [tag.replace("_", " ").replace('"', '').capitalize() for tag in tags]
6517
-
7628
+ smxAI = "smx-Orion"
7629
+
6518
7630
  return render_template(
6519
7631
  "dashboard.html",
6520
7632
  section=section,
@@ -6525,10 +7637,11 @@ def setup_routes(smx):
6525
7637
  highlighted_ai_code=highlighted_ai_code if ai_code else None,
6526
7638
  askai_question=smx.sanitize_rough_to_markdown_task(askai_question),
6527
7639
  refined_question=refined_question,
6528
- tasks=tasks,
7640
+ tasks=tags,
7641
+ smxAI=smxAI,
6529
7642
  data_cells=data_cells,
6530
7643
  session_id=session_id,
6531
- llm_usage=llm_usage
7644
+ TOKENS=TOKENS
6532
7645
  )
6533
7646
 
6534
7647
 
@@ -6589,6 +7702,179 @@ def setup_routes(smx):
6589
7702
  # go back to the dashboard; dashboard() will auto-select the next file
6590
7703
  return redirect(url_for("dashboard"))
6591
7704
 
7705
+ # ── DATASET RESIZE (independent helper page) -------------------------
7706
+
7707
+
7708
+ @smx.app.route("/dataset/resize", methods=["GET", "POST"])
7709
+ def dataset_resize():
7710
+ """
7711
+ User uploads any CSV and picks a target size (percentage of rows).
7712
+ We keep the last resized CSV in memory and expose a download link.
7713
+ """
7714
+ # One id per browser session to index _last_resized_csv
7715
+ resize_id = session.get("dataset_resize_id")
7716
+ if not resize_id:
7717
+ resize_id = str(uuid.uuid4())
7718
+ session["dataset_resize_id"] = resize_id
7719
+
7720
+ resize_info = None # stats we pass down to the template
7721
+
7722
+ if request.method == "POST":
7723
+ file = request.files.get("dataset_file")
7724
+ target_pct_raw = (request.form.get("target_pct") or "").strip()
7725
+ strat_col = (request.form.get("strat_col") or "").strip()
7726
+
7727
+ error_msg = None
7728
+ df = None
7729
+
7730
+ # --- Basic validation ---
7731
+ if not file or file.filename == "":
7732
+ error_msg = "Please choose a CSV file."
7733
+ elif not file.filename.lower().endswith(".csv"):
7734
+ error_msg = "Only CSV files are supported."
7735
+
7736
+ # --- Read CSV into a DataFrame ---
7737
+ if not error_msg:
7738
+ try:
7739
+ df = pd.read_csv(file)
7740
+ except Exception as e:
7741
+ error_msg = f"Could not read CSV: {e}"
7742
+
7743
+ # --- Parse target percentage ---
7744
+ pct = None
7745
+ if not error_msg:
7746
+ try:
7747
+ pct = float(target_pct_raw)
7748
+ except Exception:
7749
+ error_msg = "Target size must be a number between 1 and 100."
7750
+
7751
+ if not error_msg and (pct <= 0 or pct > 100):
7752
+ error_msg = "Target size must be between 1 and 100."
7753
+
7754
+ if error_msg:
7755
+ flash(error_msg, "error")
7756
+ else:
7757
+ frac = pct / 100.0
7758
+ n_orig = len(df)
7759
+ n_target = max(1, int(round(n_orig * frac)))
7760
+
7761
+ df_resized = None
7762
+ used_strat = False
7763
+
7764
+ # --- Advanced: stratified sampling by a column (behind 'Show advanced options') ---
7765
+ if strat_col and strat_col in df.columns and n_orig > 0:
7766
+ used_strat = True
7767
+ groups = df.groupby(strat_col, sort=False)
7768
+
7769
+ # First pass: proportional allocation with rounding and minimum 1 per non-empty group
7770
+ allocations = {}
7771
+ total_alloc = 0
7772
+ for key, group in groups:
7773
+ size = len(group)
7774
+ if size <= 0:
7775
+ allocations[key] = 0
7776
+ continue
7777
+ alloc = int(round(size * frac))
7778
+ if alloc == 0 and size > 0:
7779
+ alloc = 1
7780
+ if alloc > size:
7781
+ alloc = size
7782
+ allocations[key] = alloc
7783
+ total_alloc += alloc
7784
+
7785
+ keys = list(allocations.keys())
7786
+
7787
+ # Adjust downwards if we overshot
7788
+ if total_alloc > n_target:
7789
+ idx = 0
7790
+ while total_alloc > n_target and any(v > 1 for v in allocations.values()):
7791
+ k = keys[idx % len(keys)]
7792
+ if allocations[k] > 1:
7793
+ allocations[k] -= 1
7794
+ total_alloc -= 1
7795
+ idx += 1
7796
+
7797
+ # Adjust upwards if we undershot and we still have room in groups
7798
+ if total_alloc < n_target and keys:
7799
+ idx = 0
7800
+ while total_alloc < n_target:
7801
+ k = keys[idx % len(keys)]
7802
+ group_size = len(groups.get_group(k))
7803
+ if allocations[k] < group_size:
7804
+ allocations[k] += 1
7805
+ total_alloc += 1
7806
+ idx += 1
7807
+ if idx > len(keys) * 3:
7808
+ break
7809
+
7810
+ sampled_parts = []
7811
+ for key, group in groups:
7812
+ n_g = allocations.get(key, 0)
7813
+ if n_g > 0:
7814
+ sampled_parts.append(group.sample(n=n_g, random_state=0))
7815
+
7816
+ if sampled_parts:
7817
+ df_resized = (
7818
+ pd.concat(sampled_parts, axis=0)
7819
+ .sample(frac=1.0, random_state=0)
7820
+ .reset_index(drop=True)
7821
+ )
7822
+
7823
+ # --- Default: simple random sample over all rows ---
7824
+ if df_resized is None:
7825
+ if n_target >= n_orig:
7826
+ df_resized = df.copy()
7827
+ else:
7828
+ df_resized = df.sample(n=n_target, random_state=0).reset_index(drop=True)
7829
+ if strat_col and strat_col not in df.columns:
7830
+ flash(
7831
+ f"Column '{strat_col}' not found. Used simple random sampling instead.",
7832
+ "warning",
7833
+ )
7834
+
7835
+ # --- Serialise to CSV in memory and stash in _last_resized_csv ---
7836
+ buf = _std_io.BytesIO()
7837
+ df_resized.to_csv(buf, index=False)
7838
+ buf.seek(0)
7839
+ _last_resized_csv[resize_id] = buf.getvalue()
7840
+
7841
+ resize_info = {
7842
+ "rows_in": n_orig,
7843
+ "rows_out": len(df_resized),
7844
+ "pct": pct,
7845
+ "used_strat": used_strat,
7846
+ "strat_col": strat_col if used_strat else "",
7847
+ }
7848
+ flash("Dataset resized successfully. Use the download link below.", "success")
7849
+
7850
+ return render_template("dataset_resize.html", resize_info=resize_info)
7851
+
7852
+ @smx.app.route("/dataset/resize/download", methods=["GET"])
7853
+ def download_resized_dataset():
7854
+ """Download the last resized dataset for this browser session as a CSV."""
7855
+ resize_id = session.get("dataset_resize_id")
7856
+ if not resize_id:
7857
+ return ("No resized dataset available.", 404)
7858
+
7859
+ data = _last_resized_csv.get(resize_id)
7860
+ if not data:
7861
+ return ("No resized dataset available.", 404)
7862
+
7863
+ buf = _std_io.BytesIO(data)
7864
+ buf.seek(0)
7865
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
7866
+ filename = f"resized_dataset_{stamp}.csv"
7867
+
7868
+ # Drop it from memory once downloaded
7869
+ _last_resized_csv.pop(resize_id, None)
7870
+
7871
+ return send_file(
7872
+ buf,
7873
+ mimetype="text/csv; charset=utf-8",
7874
+ as_attachment=True,
7875
+ download_name=filename,
7876
+ )
7877
+
6592
7878
 
6593
7879
  def _pdf_fallback_reportlab(full_html: str):
6594
7880
  """ReportLab fallback: extract text + base64 <img> and lay them out."""
@@ -6682,4 +7968,5 @@ def setup_routes(smx):
6682
7968
  {footer}
6683
7969
  </body>
6684
7970
  </html>
6685
- """, error_message=str(e)), 500
7971
+ """, error_message=str(e)), 500
7972
+