syntaxmatrix 2.5.5.5__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 (44) hide show
  1. syntaxmatrix/__init__.py +3 -2
  2. syntaxmatrix/agentic/agents.py +1220 -169
  3. syntaxmatrix/agentic/agents_orchestrer.py +326 -0
  4. syntaxmatrix/agentic/code_tools_registry.py +27 -32
  5. syntaxmatrix/auth.py +142 -5
  6. syntaxmatrix/commentary.py +16 -16
  7. syntaxmatrix/core.py +192 -84
  8. syntaxmatrix/db.py +460 -4
  9. syntaxmatrix/{display.py → display_html.py} +2 -6
  10. syntaxmatrix/gpt_models_latest.py +1 -1
  11. syntaxmatrix/media/__init__.py +0 -0
  12. syntaxmatrix/media/media_pixabay.py +277 -0
  13. syntaxmatrix/models.py +1 -1
  14. syntaxmatrix/page_builder_defaults.py +183 -0
  15. syntaxmatrix/page_builder_generation.py +1122 -0
  16. syntaxmatrix/page_layout_contract.py +644 -0
  17. syntaxmatrix/page_patch_publish.py +1471 -0
  18. syntaxmatrix/preface.py +670 -0
  19. syntaxmatrix/profiles.py +28 -10
  20. syntaxmatrix/routes.py +1941 -593
  21. syntaxmatrix/selftest_page_templates.py +360 -0
  22. syntaxmatrix/settings/client_items.py +28 -0
  23. syntaxmatrix/settings/model_map.py +1022 -207
  24. syntaxmatrix/settings/prompts.py +328 -130
  25. syntaxmatrix/static/assets/hero-default.svg +22 -0
  26. syntaxmatrix/static/icons/bot-icon.png +0 -0
  27. syntaxmatrix/static/icons/favicon.png +0 -0
  28. syntaxmatrix/static/icons/logo.png +0 -0
  29. syntaxmatrix/static/icons/logo3.png +0 -0
  30. syntaxmatrix/templates/admin_branding.html +104 -0
  31. syntaxmatrix/templates/admin_features.html +63 -0
  32. syntaxmatrix/templates/admin_secretes.html +108 -0
  33. syntaxmatrix/templates/change_password.html +124 -0
  34. syntaxmatrix/templates/dashboard.html +296 -131
  35. syntaxmatrix/templates/dataset_resize.html +535 -0
  36. syntaxmatrix/templates/edit_page.html +2535 -0
  37. syntaxmatrix/utils.py +2728 -2835
  38. {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/METADATA +6 -2
  39. {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/RECORD +42 -25
  40. syntaxmatrix/generate_page.py +0 -634
  41. syntaxmatrix/static/icons/hero_bg.jpg +0 -0
  42. {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/WHEEL +0 -0
  43. {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/licenses/LICENSE.txt +0 -0
  44. {syntaxmatrix-2.5.5.5.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,13 +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
38
- from syntaxmatrix import auth as _auth
33
+ from syntaxmatrix.page_builder_defaults import make_default_layout, layout_to_html
34
+ from syntaxmatrix import auth as _auth
35
+ from syntaxmatrix.auth import register_user, authenticate, login_required, admin_required, superadmin_required, update_password
39
36
  from syntaxmatrix import profiles as _prof
40
37
  from syntaxmatrix.gpt_models_latest import set_args, extract_output_text as _out
41
- 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
+ )
42
58
 
43
59
  try:
44
60
  from pygments import highlight as _hl
@@ -48,21 +64,11 @@ try:
48
64
  except Exception:
49
65
  _HAVE_PYGMENTS = False
50
66
 
51
- # from syntaxmatrix.utils import *
52
- from syntaxmatrix.utils import (
53
- auto_inject_template, drop_bad_classification_metrics, ensure_accuracy_block,
54
- ensure_image_output, ensure_output, fix_plain_prints, fix_print_html, patch_fix_sentinel_plot_calls,
55
- patch_pairplot, fix_to_datetime_errors, harden_ai_code, patch_ensure_seaborn_import, get_plotting_imports,
56
- 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
57
- )
58
-
59
- from syntaxmatrix.agentic.agent_tools import ToolRunner
60
- from syntaxmatrix.agentic.code_tools_registry import EARLY_SANITIZERS, SYNTAX_AND_REPAIR
61
-
62
67
  _CLIENT_DIR = detect_project_root()
63
68
  _stream_q = queue.Queue()
64
69
  _stream_cancelled = {}
65
70
  _last_result_html = {} # { session_id: html_doc }
71
+ _last_resized_csv = {} # { resize_id: bytes for last resized CSV per browser session }
66
72
 
67
73
  # single, reused formatter: inline styles, padding, rounded corners, scroll
68
74
  _FMT = _HtmlFmt(
@@ -120,6 +126,7 @@ def get_contrast_color(hex_color: str) -> str:
120
126
  def render_chat_history(smx):
121
127
  plottings_html = smx.get_plottings()
122
128
  messages = smx.get_chat_history() or []
129
+
123
130
  chat_html = ""
124
131
  if not messages and not plottings_html:
125
132
  chat_html += f"""
@@ -169,27 +176,43 @@ def setup_routes(smx):
169
176
  os.makedirs(DATA_FOLDER, exist_ok=True)
170
177
 
171
178
  MEDIA_FOLDER = os.path.join(_CLIENT_DIR, "uploads", "media")
172
- if not os.path.exists(MEDIA_FOLDER):
173
- 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)
174
197
 
175
198
  def _evict_profile_caches_by_name(prof_name: str) -> None:
176
- """
177
- Clear any in-memory profile cache on `smx` that points to the deleted profile.
178
- Future-proof: it scans all attributes and clears any dict whose 'name' matches.
179
- """
180
- if not prof_name:
181
- return
182
- try:
183
- for attr in dir(smx):
184
- # be generous: match anything that mentions 'profile' in its name
185
- if "profile" not in attr.lower():
186
- continue
187
- val = getattr(smx, attr, None)
188
- if isinstance(val, dict) and val.get("name") == prof_name:
189
- setattr(smx, attr, {}) # drop just this one; others untouched
190
- except Exception:
191
- # never let cache eviction break the request path
192
- 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
193
216
 
194
217
  @smx.app.after_request
195
218
  def _set_session_cookie(resp):
@@ -219,24 +242,30 @@ def setup_routes(smx):
219
242
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
220
243
  <title>{smx.page}</title>
221
244
  <style>
245
+ /* ----- HTML/BODY ----------------------------------- */
246
+ html {{
247
+ font-size: clamp(12px, 1.7vw, 18px);
248
+ /* scrollbar-gutter: stable both-edges; */
249
+ }}
222
250
  body {{
223
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
224
- margin: 0 20px;
225
251
  padding: 0;
252
+ margin: 0;
226
253
  background: {smx.theme["background"]};
227
254
  color: {smx.theme["text_color"]};
228
255
  }}
229
- /* Responsive typography using clamp */
230
- html {{
231
- font-size: clamp(12px, 1.7vw, 18px);
232
- }}
256
+ html, body {{ scroll-behavior: auto; }}
257
+ .admin-grid, .admin-shell .card {{ min-width: 0; }}
258
+ html, body, .admin-shell {{ overflow-x: visible !important; }}
259
+ </style>
260
+ <style>
261
+ /* ----- NAVBAR -------------------------------- */
233
262
  /* Desktop Navbar */
234
263
  nav {{
235
264
  display: flex;
236
265
  justify-content: space-between;
237
266
  align-items: center;
238
267
  background: {smx.theme["nav_background"]};
239
- padding: 10px 20px;
268
+ padding: 10px 24px;
240
269
  position: fixed;
241
270
  top: 0;
242
271
  left: 0;
@@ -246,19 +275,33 @@ def setup_routes(smx):
246
275
  .nav-left {{
247
276
  display: flex;
248
277
  align-items: center;
249
- }}
250
- .nav-left .logo {{
251
- font-size: clamp(1.3rem, 2vw, 1.5rem);
252
- font-weight: bold;
253
278
  color: {smx.theme["nav_text"]};
254
- margin-right: 20px;
279
+ gap: 8px;
255
280
  }}
256
- .nav-left .nav-links a {{
257
- font-size: clamp(1rem, 1.2vw, 1.2rem);
281
+ .nav-left .logo {{
282
+ align-items: center;
283
+ font-weight: bold;
284
+ font-size: clamp(1.4rem, 1.8vw, 1.8rem);
285
+ margin-right: 0;
286
+ }}
287
+
288
+ .nav-left a {{
258
289
  color: {smx.theme["nav_text"]};
259
290
  text-decoration: none;
260
291
  margin-right: 15px;
261
292
  }}
293
+
294
+ .nav-left .nav-links a.active,
295
+ .nav-left .nav-links a.active:hover,
296
+ #mobile-nav a.active,
297
+ #mobile-nav a.active:hover {{
298
+ background-color: var(--nav-bg) !important; /* keep the same base */
299
+ box-shadow: inset 0 0 0 9999px rgba(0,0,0,.52); /* darken ~52% */
300
+ border-radius: 6px;
301
+ padding: 2px 8px;
302
+ color:cyan;
303
+ }}
304
+
262
305
  .nav-right a {{
263
306
  font-size: clamp(1rem, 1.2vw, 1.2rem);
264
307
  color: {smx.theme["nav_text"]};
@@ -266,9 +309,13 @@ def setup_routes(smx):
266
309
  }}
267
310
  /* Hamburger button (hidden on desktop) */
268
311
  #hamburger-btn {{
269
- display: none;
270
- 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;
271
317
  font-size: 2rem;
318
+ line-height: 1;
272
319
  background: none;
273
320
  border: none;
274
321
  color: {smx.theme["nav_text"]};
@@ -289,7 +336,7 @@ def setup_routes(smx):
289
336
  display: flex;
290
337
  flex-direction: column;
291
338
  gap: 10px;
292
- z-index: 900;
339
+ z-index: 1000;
293
340
  color: {mobile_text_color};
294
341
  }}
295
342
  #mobile-nav a {{
@@ -308,24 +355,29 @@ def setup_routes(smx):
308
355
  /* Responsive adjustments for mobile */
309
356
  @media (max-width: 768px) {{
310
357
  .nav-left .nav-links, .nav-right {{
311
- display: none;
358
+ display: none;
312
359
  }}
313
360
  #hamburger-btn {{
314
361
  display: block;
315
362
  }}
316
-
363
+ body {{
364
+ padding: 0 10px;
365
+ }}
317
366
  }}
318
- /* Sidebar styles */
367
+ </style>
368
+
369
+ <style>
370
+ /* ----- SIDEBAR ---------------------------------------------------------- */
319
371
  #sidebar {{
320
372
  position: fixed;
321
373
  top: 40px;
322
- left: -240px;
374
+ left: -260px;
323
375
  width: var(--sidebar-w);
324
- height: calc(100% - 10px);
376
+ height: calc(100% - 2px);
325
377
  background: {smx.theme["sidebar_background"]};
326
378
  overflow-y: auto;
327
379
  padding: 10px; 5px;
328
- font-size: 1.2rem;
380
+ font-size: clamp(1.2rem, 1.4vw, 1.6rem);
329
381
  gap: 10px;
330
382
  box-shadow: 2px 0 5px rgba(0,0,0,0.3);
331
383
  transition: left 0.3s ease;
@@ -334,7 +386,7 @@ def setup_routes(smx):
334
386
  }}
335
387
  #sidebar a {{
336
388
  color: {get_contrast_color(smx.theme["sidebar_background"])};
337
- margin:3px;
389
+ padding:3px;
338
390
  text-decoration: none;
339
391
  }}
340
392
  #sidebar.open {{
@@ -359,18 +411,28 @@ def setup_routes(smx):
359
411
  background-color: rgba(0, 0, 0, 0.05);
360
412
  transform: scale(1.2);
361
413
  }}
414
+ </style>
415
+ <style>
416
+ /* ----- CHAT HISTORY ---------------------------------------------------- */
362
417
  #chat-history {{
363
418
  width: 100%;
364
419
  max-width: 980px;
365
- margin: 50px auto 10px auto;
366
- padding: 10px 5px;
367
420
  background: {smx.theme["chat_background"]};
368
421
  border-radius: 20px;
369
422
  overflow-y: auto;
370
423
  min-height: 360px;
424
+ margin: 50px auto 10px auto;
425
+ padding: 10px 5px 0 5px;
426
+ padding-bottom: calc(var(--composer-h, 104px) + 78);
371
427
  }}
428
+ #chat-history .chat-message {{
429
+ scroll-margin-bottom: calc(var(--composer-h, 104px) + 78);
430
+ }}
431
+ #chat-history, #widget-container {{ overflow-anchor: none; }}
432
+
372
433
  #chat-history-default {{
373
434
  width: 100%;
435
+ max-width: 100%;
374
436
  margin: 45px auto 10px auto;
375
437
  padding: 10px 5px;
376
438
  background: {smx.theme["chat_background"]};
@@ -384,13 +446,13 @@ def setup_routes(smx):
384
446
  transform:scale(1.2);
385
447
  transition: all 0.3s ease;
386
448
  }}
449
+
450
+ { _chat_css() }
451
+
387
452
  #widget-container {{
388
- max-width: 850px;
453
+ max-width: 100%;
389
454
  margin: 0 auto 40px auto;
390
455
  }}
391
-
392
- { _chat_css() }
393
-
394
456
  .closeable-div {{
395
457
  position: relative;
396
458
  padding: 20px;
@@ -410,27 +472,20 @@ def setup_routes(smx):
410
472
  .close-btn:hover {{
411
473
  color: #ff0000;
412
474
  }}
413
- </style>
414
- <style>
475
+
415
476
  @keyframes spin {{
416
477
  0% {{ transform: rotate(0deg); }}
417
478
  100% {{ transform: rotate(360deg); }}
418
479
  }}
419
-
420
- </style>
421
- <style>
422
480
  .dropdown:hover .dropdown-content {{
423
481
  display: block;
424
482
  }}
425
- </style>
426
- <style>
427
483
  /* Keep the shift amount equal to the actual sidebar width */
428
484
  :root {{ --sidebar-w: 16vw; --nav-bg: {{smx.theme["nav_background"]}}; }}
429
485
 
430
486
  /* Messages slide; composer doesn't stay shifted */
431
- #chat-history,
432
- #widget-container {{ transition: transform .45s ease; }}
433
-
487
+ #chat-history, #widget-container {{ transition: transform .45s ease; }}
488
+
434
489
  /* Messages move fully clear of the sidebar */
435
490
  body.sidebar-open #chat-history {{ transform: translateX(calc(var(--sidebar-w) * 0.30)); }}
436
491
 
@@ -447,56 +502,28 @@ def setup_routes(smx):
447
502
  #widget-container, #smx-widgets {{
448
503
  position: sticky;
449
504
  bottom: 0;
450
- z-index: 1100; /* > sidebar (999) */
505
+ z-index: 1100;
451
506
  background: inherit;
452
507
  }}
453
- #chat-history{{
454
- padding-bottom: calc(var(--composer-h, 104px) + 78);
455
- }}
456
- #chat-history .chat-message {{
457
- scroll-margin-bottom: calc(var(--composer-h, 104px) + 78);
458
- }}
459
- /* Stop browser scroll-anchoring from fighting our autoscroll */
460
- #chat-history,
461
- #widget-container {{
462
- overflow-anchor: none;
463
- }}
464
-
465
- /* Reduce visual “jump” when the scrollbar appears/disappears */
466
- html {{
467
- scrollbar-gutter: stable both-edges;
468
- }}
469
-
470
- /* Avoid unexpected smooth scrolling that can look like a jerk */
471
- html, body {{
472
- scroll-behavior: auto;
473
- }}
474
-
508
+
475
509
  /* Textarea bounds */
476
510
  .chat-composer {{ min-width:0; max-height:12vh; }}
477
- @media (max-width:900px){{
511
+ @media (max-width:1200px){{
478
512
  .chat-composer {{
479
513
  min-height:56px;
480
514
  line-height:1.4;
481
515
  white-space: pre-wrap;
482
- padding: 10px 10px 16px 24px;
483
- font-size: 16px; /* prevents iOS zoom + improves legibility */
484
- overflow-y: auto; /* scroll after cap */
516
+ padding: 10px 10px 16px 12px;
517
+ font-size: 16px;
518
+ overflow-y: auto;
485
519
  box-sizing: border-box;
486
520
  }}
487
521
  }}
488
-
489
- .nav-left .nav-links a.active,
490
- .nav-left .nav-links a.active:hover,
491
- #mobile-nav a.active,
492
- #mobile-nav a.active:hover {{
493
- background-color: var(--nav-bg) !important; /* keep the same base */
494
- box-shadow: inset 0 0 0 9999px rgba(0,0,0,.52); /* darken ~52% */
495
- border-radius: 6px;
496
- padding: 2px 8px;
497
- color:cyan;
522
+ @media (max-width:900px){{
523
+ #chat-history {{
524
+ padding-top: 62px;
525
+ }}
498
526
  }}
499
-
500
527
  </style>
501
528
 
502
529
  <!-- Add MathJax -->
@@ -513,7 +540,7 @@ def setup_routes(smx):
513
540
  }});
514
541
  </script>
515
542
  <script>
516
- /** Turn the latest bot <p> into fade-in “lines” and reveal them sequentially */
543
+ // Turn the latest bot <p> into fade-in “lines” and reveal them sequentially
517
544
  function splitToLines(node){{
518
545
  // If there are list items, animate them item-by-item.
519
546
  const lis = node.querySelectorAll('li');
@@ -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,10 +711,12 @@ def setup_routes(smx):
629
711
  '</form>'
630
712
  )
631
713
  else:
714
+ reg_link = ""
715
+ if getattr(smx, "registration_enabled", False):
716
+ reg_link = f'|<a href="{url_for("register")}" class="nav-link">Register</a>'
632
717
  auth_links = (
633
718
  f'<a href="{url_for("login")}" class="nav-link">Login</a>'
634
- '|'
635
- f'<a href="{url_for("register")}" class="nav-link">Register</a>'
719
+ f'{reg_link}'
636
720
  )
637
721
 
638
722
  desktop_nav = f"""
@@ -664,7 +748,6 @@ def setup_routes(smx):
664
748
  {hamburger_btn}
665
749
  </nav>
666
750
  {mobile_nav}
667
- {hamburger_btn}
668
751
  """
669
752
 
670
753
  def footer_html():
@@ -708,7 +791,7 @@ def setup_routes(smx):
708
791
  .chat-message.user {{
709
792
  background: #e4e8ed;
710
793
  float: right;
711
- margin-right: 15px;
794
+ margin-right: 20px;
712
795
  border-top-right-radius: 2px;
713
796
  }}
714
797
  .chat-message.user::after {{
@@ -1501,33 +1584,7 @@ def setup_routes(smx):
1501
1584
  @smx.app.route("/", methods=["GET", "POST"])
1502
1585
  def home():
1503
1586
  smx.page = ""
1504
- # if not session.get("current_session"):
1505
- # session["current_session"] = {"id": str(uuid.uuid4()), "title": "Current", "history": []}
1506
- # session.setdefault("past_sessions", [])
1507
- # session.setdefault("chat_history", [])
1508
- # session["active_chat_id"] = session["current_session"]["id"]
1509
-
1510
- # if session.pop("needs_end_chat", False):
1511
- # current_history = session.get("chat_history", [])
1512
- # current_session = session.get("current_session", {"id": str(uuid.uuid4()), "title": "Current", "history": []})
1513
- # past_sessions = session.get("past_sessions", [])
1514
-
1515
- # generated_title = smx.generate_contextual_title(current_history)
1516
- # current_session["title"] = generated_title
1517
- # past_sessions.insert(0, {"id": current_session["id"], "title": current_session["title"]})
1518
-
1519
- # session["past_sessions"] = past_sessions
1520
- # session["current_session"] = {"id": str(uuid.uuid4()), "title": "Current", "history": []}
1521
- # session["active_chat_id"] = session["current_session"]["id"]
1522
- # session["chat_history"] = []
1523
-
1524
- # session["user_query"] = ""
1525
- # session["app_token"] = smx.app_token
1526
-
1527
- # cur = session.get("current_session")
1528
- # if cur and cur.get("id") and session.get("active_chat_id") != cur["id"]:
1529
- # session["active_chat_id"] = cur["id"]
1530
-
1587
+
1531
1588
  if not session.get("current_session"):
1532
1589
  # metadata only: id + title
1533
1590
  session["current_session"] = {"id": str(uuid.uuid4()), "title": "Current"}
@@ -2397,31 +2454,18 @@ def setup_routes(smx):
2397
2454
  home_page_html = f"""
2398
2455
  {head_html()}
2399
2456
  <meta name="viewport" content="width=device-width, initial-scale=1" />
2457
+
2400
2458
  <style>
2401
- /* Match /dashboard font scale */
2402
- :root{{
2403
- --smx-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
2404
- Arial, "Noto Sans", "Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
2405
- --smx-font-size: 16px; /* >=16px prevents iOS zoom */
2406
- --smx-line: 1.55;
2407
- }}
2408
- html{{ -webkit-text-size-adjust: 100%; }}
2409
- body{{ font-family: var(--smx-font); font-size: var(--smx-font-size); line-height: var(--smx-line); }}
2410
- </style>
2411
- <style>
2412
- /* Container sizing & equal gutters */
2413
2459
  .chat-container{{
2414
2460
  max-width: 820px;
2415
2461
  margin-inline: auto;
2416
2462
  padding-inline: 12px;
2417
2463
  box-sizing: border-box;
2418
2464
  }}
2419
-
2420
- /* Bubbles never overflow the viewport width */
2421
2465
  .chat-messages{{
2422
2466
  overflow-wrap: anywhere;
2423
2467
  word-break: break-word;
2424
- padding-bottom: 84px; /* room for sticky footer input */
2468
+ padding-bottom: 84px;
2425
2469
  }}
2426
2470
 
2427
2471
  /* Sticky footer input area (safe on iOS address-bar) */
@@ -2466,8 +2510,7 @@ def setup_routes(smx):
2466
2510
  @supports (-webkit-touch-callout: none){{
2467
2511
  .chat-footer textarea{{ resize: none; }}
2468
2512
  }}
2469
- </style>
2470
- <style>
2513
+
2471
2514
  /* Desktop: push chat-history a little more than the base shift */
2472
2515
  body.sidebar-open #chat-history{{
2473
2516
  transform: translateX(calc(var(--sidebar-shift, var(--sidebar-w)) - 90px));
@@ -2499,10 +2542,9 @@ def setup_routes(smx):
2499
2542
  }}
2500
2543
  /* Typewriter look during streaming only */
2501
2544
  .chat-message.bot.streaming .stream-target{{
2502
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
2503
2545
  font-variant-ligatures: none;
2504
2546
  white-space: pre-wrap; /* keep line breaks as they stream */
2505
- letter-spacing: 0.01em; /* subtle spacing for “typed” feel */
2547
+ letter-spacing: 0.02em; /* subtle spacing for “typed” feel */
2506
2548
  }}
2507
2549
 
2508
2550
  /* Blinking caret visible only while streaming */
@@ -2525,8 +2567,7 @@ def setup_routes(smx):
2525
2567
  0%, 100% {{ opacity: 0; }}
2526
2568
  50% {{ opacity: 1; }}
2527
2569
  }}
2528
- </style>
2529
- <style id="smx-structured-style">
2570
+
2530
2571
  /* Container for structured bot content */
2531
2572
  .chat-message.bot .smx-structured {{
2532
2573
  margin-top: 4px;
@@ -2540,10 +2581,11 @@ def setup_routes(smx):
2540
2581
  margin: 8px 0 4px;
2541
2582
  font-weight: 700;
2542
2583
  }}
2543
- .chat-message.bot .smx-structured h1 {{ font-size: 1.15rem; }}
2544
- .chat-message.bot .smx-structured h2 {{ font-size: 1.06rem; }}
2545
- .chat-message.bot .smx-structured h3 {{ font-size: 1.0rem; }}
2546
-
2584
+
2585
+ .chat-message.bot .smx-structured h1 {{ font-size: 1.3rem; }}
2586
+ .chat-message.bot .smx-structured h2 {{ font-size: 1.2rem; }}
2587
+ .chat-message.bot .smx-structured h3 {{ font-size: 1.1rem; }}
2588
+
2547
2589
  /* Paragraphs */
2548
2590
  .chat-message.bot .smx-structured p {{
2549
2591
  margin: 6px 0;
@@ -2563,15 +2605,13 @@ def setup_routes(smx):
2563
2605
  padding: 8px 10px;
2564
2606
  border-radius: 8px;
2565
2607
  overflow: auto;
2566
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
2567
2608
  }}
2568
2609
 
2569
- /* While streaming we still use a live typing box */
2610
+ /* While streaming, still use a live typing box */
2570
2611
  .chat-message.bot.streaming .stream-target {{
2571
2612
  white-space: pre-wrap; /* so newlines render during typing */
2572
2613
  }}
2573
- </style>
2574
- <style>
2614
+
2575
2615
  /* --- Stop-in-a-ring spinner --- */
2576
2616
  #submit-button.stop {{
2577
2617
  display: inline-flex;
@@ -2611,8 +2651,7 @@ def setup_routes(smx):
2611
2651
  @keyframes smxSpin {{
2612
2652
  to {{ transform: rotate(360deg); }}
2613
2653
  }}
2614
- </style>
2615
- <style>
2654
+
2616
2655
  /* Force strict top→bottom stacking and align sides without floats */
2617
2656
  #chat-history{{
2618
2657
  display: flex;
@@ -2661,6 +2700,14 @@ def setup_routes(smx):
2661
2700
  body {{
2662
2701
  padding-bottom:0;
2663
2702
  }}
2703
+
2704
+ html, body {{
2705
+ margin: 0;
2706
+ padding: 0;
2707
+ width: 100%;
2708
+ height: 100%; /* If you want it to be full height too */
2709
+ overflow-x: hidden; /* Optional: Prevents horizontal scrollbar appearing if a slight overflow */
2710
+ }}
2664
2711
  </style>
2665
2712
 
2666
2713
  <body>
@@ -3080,7 +3127,7 @@ def setup_routes(smx):
3080
3127
  }) + "\n\n"
3081
3128
 
3082
3129
  except GeneratorExit:
3083
- smx.info("Client aborted the stream.")
3130
+ return "Client aborted the stream."
3084
3131
  except Exception as e:
3085
3132
  smx.error(f"Stream error: {e}")
3086
3133
  yield "data: " + json.dumps({"event": "error", "error": str(e)}) + "\n\n"
@@ -3111,7 +3158,7 @@ def setup_routes(smx):
3111
3158
  yield "data: " + json.dumps({ "event": "cancelled" }) + "\n\n"
3112
3159
 
3113
3160
  try:
3114
- gen = smx.process_query_stream(**sa) # <- yes, this is where streaming is used
3161
+ gen = smx.process_query_stream(**sa)
3115
3162
  except Exception as e:
3116
3163
  smx.error(f"Could not start stream: {e}")
3117
3164
  return jsonify({"error": "stream_start_failed", "message": str(e)})
@@ -3279,6 +3326,10 @@ def setup_routes(smx):
3279
3326
  def upload_user_file():
3280
3327
  import uuid
3281
3328
  from flask import jsonify
3329
+
3330
+ if not getattr(smx, "user_files_enabled", False):
3331
+ return jsonify({"error": "user_files_disabled"}), 403
3332
+
3282
3333
  # Define the upload folder for user files.
3283
3334
  upload_folder = os.path.join(_CLIENT_DIR, "uploads", "user")
3284
3335
  if not os.path.exists(upload_folder):
@@ -3383,28 +3434,43 @@ def setup_routes(smx):
3383
3434
  }
3384
3435
  .admin-sidenav a:hover,.admin-sidenav a.active{background:#DADADA}
3385
3436
 
3386
- /* Main content with balanced margins */
3387
- @media (min-width: 901px){
3388
- .admin-main{
3389
- margin-left: calc(var(--sidenav-w) + 3px); /* 1px for the border */
3390
- margin-top: var(--nav-h);
3391
- margin-bottom: 0;
3392
- padding: 0 10px; /* keep your left gutter */
3393
- margin-right: 0 !important; /* stop over-wide total */
3394
- width: calc(100% - var(--sidenav-w)) !important; /* % not vw */
3395
- padding-right: var(--right) !important; /* keep your right gutter */
3396
- box-sizing: border-box;
3397
- 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;
3398
3446
  }
3399
- @media (max-width: 768px) {
3400
- body {
3401
- padding-top: 0;
3402
- }
3447
+ .admin-scrim.show{
3448
+ opacity: 1;
3449
+ pointer-events: auto;
3403
3450
  }
3404
- /* undo the mobile overflow clamp on large screens */
3405
- html, body, .admin-shell{ overflow-x: visible !important; }
3406
- /* guard against grid items forcing overflow */
3407
- .admin-grid, .admin-shell .card { min-width: 0; }
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;
3459
+ }
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%;
3473
+ }
3408
3474
  }
3409
3475
 
3410
3476
  /* Section demarcation */
@@ -3462,7 +3528,7 @@ def setup_routes(smx):
3462
3528
  .span-12 { grid-column: span 12; }
3463
3529
 
3464
3530
  /* Lists */
3465
- .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}
3466
3532
  .catalog-list li{
3467
3533
  display:flex;align-items:center;justify-content:space-between;gap:4px;
3468
3534
  padding:1px 2px;border-bottom:1px solid #eee;font-size:.7rem;
@@ -3506,25 +3572,66 @@ def setup_routes(smx):
3506
3572
  }
3507
3573
  }
3508
3574
 
3509
- /* Mobile */
3575
+ /* Mobile: off-canvas drawer from the left (like dashboard) */
3510
3576
  @media (max-width: 900px){
3511
- .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
+
3512
3593
  .admin-main{
3513
- margin-top: var(--nav-h);
3514
- margin-left: calc(var(--sidenav-w-sm) - 1px); /* 1px for the border */
3515
- margin-right: 4px;
3516
- width: calc(100% - var(--sidenav-w-sm));
3517
- padding: 0;
3594
+ margin-left: 0;
3595
+ margin-right: 0;
3596
+ width: 100%;
3597
+ padding: 8px 8px 16px;
3518
3598
  box-sizing: border-box;
3519
- 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: "✕";
3520
3627
  }
3521
-
3522
- /* force all grid items to stack */
3523
- .span-3, .span-4, .span-6, .span-8, .span-12 { grid-column: span 12; }
3524
- }
3525
3628
 
3526
- /* Global overflow guards (safe, won’t hide useful content inside lists) */
3527
- html, body, .admin-shell { overflow-x: hidden; }
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;
3633
+ }
3634
+ }
3528
3635
 
3529
3636
  /* Prevent any inner block from insisting on a width that causes overflow */
3530
3637
  .admin-shell .card, .admin-grid { min-width: 0; }
@@ -3601,12 +3708,13 @@ def setup_routes(smx):
3601
3708
  .catalog-list li:hover {
3602
3709
  background: #D3E3D3;
3603
3710
  }
3604
- #users > div > div > ul > li > form > button {
3711
+ #users > div > div > ul > li > form > button {
3605
3712
  font-size: 0.7rem;
3606
3713
  margin: 0;
3607
3714
  padding: 0 !important;
3608
3715
  border: 0.5px dashed gray;
3609
3716
  }
3717
+
3610
3718
  /* Fix: stop inputs/selects inside cards spilling out (desktop & tablet) */
3611
3719
  .admin-shell .card > * { min-width: 0; }
3612
3720
  .admin-shell .card input,
@@ -3619,13 +3727,53 @@ def setup_routes(smx):
3619
3727
  }
3620
3728
  .admin-shell .card input:not([type="checkbox"]):not([type="radio"]),
3621
3729
  .admin-shell .card select,
3622
- .admin-shell .card textarea{
3730
+ .admin-shell .card textarea {
3623
3731
  display:block;
3624
3732
  width:100%;
3625
3733
  max-width:100%;
3626
3734
  box-sizing:border-box;
3627
3735
  }
3628
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
+
3629
3777
  /* Restore normal checkbox/radio sizing & alignment */
3630
3778
  .admin-shell .card input[type="checkbox"],
3631
3779
  .admin-shell .card input[type="radio"]{
@@ -3645,18 +3793,13 @@ def setup_routes(smx):
3645
3793
  }
3646
3794
  /* If fixed and its height is constant (e.g., 56px) */
3647
3795
  body { padding-top: 46px; } /* make room for the bar */
3648
- .admin-main { margin-top: 0; } /* remove the manual bump */
3649
- .admin-sidenav { /* keep the sidebar aligned */
3650
- top: 56px;
3651
- height: calc(100vh - 56px);
3652
- }
3796
+
3653
3797
  #del-embed-btn, .del-btn {
3654
3798
  padding: 0;
3655
3799
  font-size: 0.6rem;
3656
3800
  border: none;
3657
3801
  text-decoration: none;
3658
3802
  }
3659
-
3660
3803
  </style>
3661
3804
  """
3662
3805
 
@@ -3708,15 +3851,199 @@ def setup_routes(smx):
3708
3851
  f"Generated {total_chunks} chunk(s)."
3709
3852
  )
3710
3853
 
3854
+
3711
3855
  elif action == "add_page":
3712
- page_name = request.form.get("page_name", "").strip()
3713
- page_name = page_name.lower()
3714
- site_desc = request.form.get("site_desc", "").strip()
3715
- 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:
3716
3888
  smx.set_website_description(site_desc)
3717
- page_content_html = _genpage.generate_page_html(page_name, smx.website_description)
3718
- if page_name and page_name not in smx.pages:
3719
- 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"))
3720
4047
 
3721
4048
 
3722
4049
  elif action == "save_llm":
@@ -3742,7 +4069,7 @@ def setup_routes(smx):
3742
4069
  prov = request.form["provider"]
3743
4070
  model = request.form["model"]
3744
4071
  tag = request.form["purpose"]
3745
- desc = request.form["desc"]
4072
+ desc = request.form["desc"]
3746
4073
 
3747
4074
  if not any(r for r in catalog if r["provider"] == prov and r["model"] == model):
3748
4075
  flash("Provider/model not in catalog", "error")
@@ -3753,7 +4080,7 @@ def setup_routes(smx):
3753
4080
  provider = request.form.get("provider", "").strip(),
3754
4081
  model = request.form.get("model", "").strip(),
3755
4082
  api_key = request.form.get("api_key", "").strip(),
3756
- purpose = request.form.get("purpose", "").strip() or "general",
4083
+ purpose = request.form.get("purpose", "").strip(),
3757
4084
  desc = request.form.get("desc", "").strip(),
3758
4085
  )
3759
4086
  _prof.refresh_profiles_cache()
@@ -3820,6 +4147,34 @@ def setup_routes(smx):
3820
4147
  )
3821
4148
  flash(f"Role '{name}' created.", "info") if ok else flash("Could not create role (reserved/exists/invalid).", "error")
3822
4149
 
4150
+ elif action == "create_user":
4151
+ viewer_role = (session.get("role") or "").lower()
4152
+ if viewer_role not in ("admin", "superadmin"):
4153
+ flash("You are not authorised to create user.", "error")
4154
+ else:
4155
+ email = (request.form.get("email") or "").strip()
4156
+ username = (request.form.get("username") or "").strip()
4157
+ temp_password = request.form.get("password") or ""
4158
+ role = (request.form.get("role") or "user").strip().lower()
4159
+
4160
+ if not email or not temp_password:
4161
+ flash("Email and password are required to create a user.", "error")
4162
+ elif role not in ("user", "employee"):
4163
+ flash("Invalid role for new user.", "error")
4164
+ else:
4165
+ ok = register_user(email, username, temp_password, role)
4166
+ if ok:
4167
+ # Force this new account to change password on first login
4168
+ _auth.set_must_reset_by_email(email, must_reset=True)
4169
+ flash(
4170
+ "User created. They must change the temporary password on first login.",
4171
+ "success",
4172
+ )
4173
+ else:
4174
+ flash("Could not create user (email or username already in use).", "error")
4175
+
4176
+ return redirect(url_for("admin_panel"))
4177
+
3823
4178
  elif action == "set_user_role":
3824
4179
  actor_role = (session.get("role") or "").lower()
3825
4180
  actor_id = session.get("user_id")
@@ -3846,7 +4201,7 @@ def setup_routes(smx):
3846
4201
 
3847
4202
  elif action == "confirm_delete_user":
3848
4203
  if (session.get("role") or "").lower() != "superadmin":
3849
- flash("Only superadmin can delete accounts.", "error")
4204
+ flash("You are not authorised to delete accounts.", "error")
3850
4205
  else:
3851
4206
  session["pending_delete_user_id"] = int(request.form.get("user_id") or 0)
3852
4207
  flash("Confirm deletion below.", "warning")
@@ -3856,7 +4211,7 @@ def setup_routes(smx):
3856
4211
 
3857
4212
  elif action == "delete_user":
3858
4213
  if (session.get("role") or "").lower() != "superadmin":
3859
- flash("You don't have permission to delete account.", "error")
4214
+ flash("You are not authorised to delete account.", "error")
3860
4215
  else:
3861
4216
  target_id = session.get("pending_delete_user_id")
3862
4217
  if target_id:
@@ -3888,7 +4243,7 @@ def setup_routes(smx):
3888
4243
  # ────────────────────────────────────────────────────────────────────────────────
3889
4244
  embedding_model = _llms.load_embed_model()
3890
4245
  embeddings_setup_card = f"""
3891
- <div class="card span-4">
4246
+ <div class="card span-3">
3892
4247
  <h4>Setup Embedding Model</h4>
3893
4248
  <form method="post" style="display:inline-block; margin-right:8px;">
3894
4249
  <input type="hidden" name="action" value="save_llm">
@@ -3949,7 +4304,7 @@ def setup_routes(smx):
3949
4304
  # LLMs
3950
4305
  # ────────────────────────────────────────────────────────────────────────────────
3951
4306
  Add_model_catalog_card = f"""
3952
- <div class="card span-4">
4307
+ <div class="card span-3">
3953
4308
  <h3>Add Model To Catalogue</h3>
3954
4309
  <form method="post" style="margin-bottom:0.5rem;">
3955
4310
  <label for="catalog_prov">Provider</label>
@@ -4080,7 +4435,7 @@ def setup_routes(smx):
4080
4435
  """
4081
4436
 
4082
4437
  models_catalog_list_card = f"""
4083
- <div class="card span-4">
4438
+ <div class="card span-6">
4084
4439
  <h4>Models Catalogue</h4>
4085
4440
  <ul class="catalog-list">
4086
4441
  {cat_items or "<li class='li-row'>No models yet.</li>"}
@@ -4118,7 +4473,7 @@ def setup_routes(smx):
4118
4473
 
4119
4474
  <input type='hidden' id='purpose-field' name='purpose'>
4120
4475
  <input type='hidden' id='desc-field' name='desc'>
4121
-
4476
+ <br>
4122
4477
  <button class='btn btn-primary' type='submit' name='action' value='add_profile'>Add / Update</button>
4123
4478
  </form>
4124
4479
  </div>
@@ -4150,15 +4505,21 @@ def setup_routes(smx):
4150
4505
  <ul class="catalog-list" style="padding-left:1rem; margin-bottom:0;">
4151
4506
  {profile_items or "<li class='li-row'>No profiles yet.</li>"}
4152
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>
4153
4513
  </div>
4154
4514
  """
4155
4515
 
4516
+
4156
4517
  # ────────────────────────────────────────────────────────────────────────────────
4157
4518
  # SYSTEM FILES
4158
4519
  # ────────────────────────────────────────────────────────────────────────────────
4159
4520
  sys_files_card = f"""
4160
- <div class="card span-6">
4161
- <h4>Upload System Files (PDFs only)</h4>
4521
+ <div class="card span-3">
4522
+ <h4>Upload System Files<br>(PDFs only)</h4>
4162
4523
  <form id="form-upload" method="post" enctype="multipart/form-data" style="display:inline-block;">
4163
4524
  <input type="file" name="upload_files" accept=".pdf" multiple>
4164
4525
  <button type="submit" name="action" value="upload_files">Upload</button>
@@ -4189,7 +4550,7 @@ def setup_routes(smx):
4189
4550
  """
4190
4551
 
4191
4552
  manage_sys_files_card = f"""
4192
- <div class='card span-6'>
4553
+ <div class='card span-3'>
4193
4554
  <h4>Manage Company Files</h4>
4194
4555
  <ul class="catalog-list" style="list-style:none; padding-left:0; margin:0;">
4195
4556
  {sys_files_html or "<li>No company file has been uploaded yet.</li>"}
@@ -4204,48 +4565,105 @@ def setup_routes(smx):
4204
4565
  upload_msg = session.pop("upload_msg", "")
4205
4566
  alert_script = f"<script>alert('{upload_msg}');</script>" if upload_msg else ""
4206
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
+
4207
4575
  pages_html = ""
4208
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
+
4209
4585
  pages_html += f"""
4210
4586
  <li class="li-row" data-row-id="{p}">
4211
- <span>{p}</span>
4212
- <span style="float:right;">
4213
- <a class="edit-btn" href="/admin/edit/{p}" title="Edit {p}">🖊️</a>
4214
- <a href="#"
4215
- class="del-btn" title="Delete {p}"
4216
- data-action="open-delete-modal"
4217
- data-delete-url="/admin/delete.json"
4218
- data-delete-field="page_name"
4219
- data-delete-id="{p}"
4220
- data-delete-label="page {p}"
4221
- data-delete-extra='{{"resource":"page"}}'
4222
- data-delete-remove="[data-row-id='{p}']">🗑️</a>
4223
- </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>
4224
4628
  </li>
4225
4629
  """
4226
4630
 
4227
4631
  add_new_page_card = f"""
4228
- <div class="card span-9">
4229
- <h4>Add New Page</h4>
4230
- <form id="form-add-page" method="post">
4231
- <input type="text" name="page_name" placeholder="Page Name" required>
4232
- <textarea name="site_desc" placeholder="Website description"></textarea>
4233
- <div style="text-align:right;">
4234
- <button type="submit" name="action" value="add_page">Add Page</button>
4235
- </div>
4236
- </form>
4237
- </div>
4238
- """
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
+ """
4239
4656
 
4240
4657
  manage_page_card = f"""
4241
- <div class="card span-3">
4658
+ <div class="card span-12">
4242
4659
  <h4>Manage Pages</h4>
4243
- <ul class="catalog-list">
4660
+ <ul id="pages-list" class="catalog-list">
4244
4661
  {pages_html or "<li>No page has been added yet.</li>"}
4245
4662
  </ul>
4246
4663
  </div>
4247
4664
  """
4248
4665
 
4666
+
4249
4667
  # ────────────────────────────────────────────────────────────────────────────────
4250
4668
  # USERS & ROLES
4251
4669
  # ────────────────────────────────────────────────────────────────────────────────
@@ -4382,14 +4800,45 @@ def setup_routes(smx):
4382
4800
  """
4383
4801
 
4384
4802
  employees_card = f"""
4385
- <div class="card span-12">
4386
- <h4>Employees</h4>
4387
- <ul class="catalog-list">
4388
- {''.join(emp_items) or "<li>No employees yet.</li>"}
4389
- </ul>
4390
- {add_form}
4391
- </div>
4803
+ <div class="card span-12">
4804
+ <h4>Employees</h4>
4805
+ <ul class="catalog-list">
4806
+ {''.join(emp_items) or "<li>No employees yet.</li>"}
4807
+ </ul>
4808
+ {add_form}
4809
+ </div>
4392
4810
  """
4811
+ # Admin-only: create users directly (useful when public registration is disabled)
4812
+ create_user_card = ""
4813
+ if viewer_role in ("admin", "superadmin"):
4814
+ create_user_card = """
4815
+ <div class="card span-4">
4816
+ <h4>Create User</h4>
4817
+ <form method="post" class="form-vertical">
4818
+ <input type="hidden" name="action" value="create_user">
4819
+ <label>Email</label>
4820
+ <input type="email" name="email" required>
4821
+
4822
+ <label>Username (optional)</label>
4823
+ <input type="text" name="username" placeholder="e.g. jsmith">
4824
+
4825
+ <label>Temporary password</label>
4826
+ <input type="password" name="password" required>
4827
+
4828
+ <label>Role</label>
4829
+ <select name="role">
4830
+ <option value="user">User</option>
4831
+ <option value="employee">Employee</option>
4832
+ </select>
4833
+
4834
+ <button type="submit" style="margin-top:.5rem;">Create User</button>
4835
+ </form>
4836
+ <p style="font-size:.75rem;opacity:.7;margin-top:.5rem;">
4837
+ Share the temporary password securely and ask the user to change it after first login.
4838
+ </p>
4839
+ </div>
4840
+ """
4841
+
4393
4842
  from datetime import datetime, timedelta
4394
4843
  # Audit (always its own row)
4395
4844
  audit_card = ""
@@ -4512,22 +4961,68 @@ def setup_routes(smx):
4512
4961
  </section>
4513
4962
  """
4514
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
+
4515
5006
  system_section = f"""
4516
5007
  <section id="system" class="section">
4517
5008
  <h2>System</h2>
4518
5009
  <div class="admin-grid">
5010
+ {secretes_link_card}
5011
+ {branding_link_card}
5012
+ {features_link_card}
4519
5013
  {sys_files_card}
4520
5014
  {manage_sys_files_card}
4521
5015
  </div>
5016
+
4522
5017
  </section>
4523
5018
  """
4524
-
4525
5019
  users_section = f"""
4526
5020
  <section id="users" class="section">
4527
5021
  <h2>Users</h2>
4528
5022
  <div class="admin-grid">
4529
5023
  {roles_card}
4530
5024
  {employees_card}
5025
+ {create_user_card}
4531
5026
  </div>
4532
5027
  </section>
4533
5028
  """
@@ -4543,15 +5038,47 @@ def setup_routes(smx):
4543
5038
 
4544
5039
  admin_shell = f"""{admin_layout_css}
4545
5040
  <div class="admin-shell">
4546
- {side_nav}
4547
- <div class="admin-main">
4548
- {models_section}
4549
- {pages_section}
4550
- {system_section}
4551
- {users_section}
4552
- {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>
4553
5053
  </div>
4554
- </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>
4555
5082
  """
4556
5083
 
4557
5084
  # ─────────────────────────────────────────────────────────
@@ -4587,16 +5114,19 @@ def setup_routes(smx):
4587
5114
  {delete_modal_block}
4588
5115
 
4589
5116
  <!-- Profiles helper scripts -->
4590
- <script>
4591
- /* Name suggestions popover */
4592
- const nameExamples = {{
4593
- 'Administration': 'Admin',
4594
- 'Chatting': 'Chat',
4595
- 'Coding': 'Coding',
4596
- 'Vision-to-text': 'Vision2Text',
4597
- 'Summarisation': 'Summarisation',
4598
- 'Classification': 'Classification',
4599
- }};
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
+
4600
5130
  const txt = document.getElementById('profile_name');
4601
5131
  const infoBtn = document.getElementById('name-help');
4602
5132
  const popover = document.getElementById('name-suggestions');
@@ -4722,7 +5252,7 @@ def setup_routes(smx):
4722
5252
 
4723
5253
  if (form) {{
4724
5254
  form.addEventListener('submit', function () {{
4725
- if (btn) {{ btn.disabled = true; btn.textContent = 'Adding…'; }}
5255
+ if (btn) {{ btn.disabled = true; btn.textContent = 'Generating…'; }}
4726
5256
  if (overlay) overlay.style.display = 'flex';
4727
5257
  }});
4728
5258
  }}
@@ -4732,7 +5262,7 @@ def setup_routes(smx):
4732
5262
  const o = document.getElementById('loader-overlay');
4733
5263
  if (o) o.style.display = 'none';
4734
5264
  const b = document.getElementById('add-page-btn');
4735
- if (b) {{ b.disabled = false; b.textContent = 'Add Page'; }}
5265
+ if (b) {{ b.disabled = false; b.textContent = 'Generate'; }}
4736
5266
  }});
4737
5267
  }});
4738
5268
  </script>
@@ -4874,7 +5404,99 @@ def setup_routes(smx):
4874
5404
  if(e.target === backdrop) closeModal();
4875
5405
  }});
4876
5406
  }})();
4877
- </script>
5407
+ </script>
5408
+
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;
5414
+
5415
+ let draggingEl = null;
5416
+
5417
+ function getPageName(li) {{
5418
+ if (!li) return '';
5419
+ if (li.dataset.pageName) return li.dataset.pageName;
5420
+
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
+
4878
5500
  </body>
4879
5501
  </html>
4880
5502
  """,
@@ -4884,6 +5506,192 @@ def setup_routes(smx):
4884
5506
  profiles=profiles
4885
5507
  )
4886
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
+
4887
5695
  @smx.app.route("/admin/delete.json", methods=["POST"])
4888
5696
  def admin_delete_universal():
4889
5697
 
@@ -5065,47 +5873,56 @@ def setup_routes(smx):
5065
5873
  smx.warning(f"/admin/delete.json error: {e}")
5066
5874
  return jsonify(ok=False, error=str(e)), 500
5067
5875
 
5068
- # Override the generic page renderer to inject a gallery on the "service" page
5876
+
5069
5877
  @smx.app.route('/page/<page_name>')
5070
5878
  def view_page(page_name):
5071
- smx.page = page_name.lower()
5072
- nav_html = _generate_nav()
5073
- content = smx.pages.get(page_name, f"No content found for page '{page_name}'.")
5074
-
5075
- # only on the service page, build a gallery
5076
- media_html = ''
5077
- if page_name.lower() == 'service':
5078
- media_folder = os.path.join(_CLIENT_DIR, 'uploads', 'media')
5079
- if os.path.isdir(media_folder):
5080
- files = sorted(os.listdir(media_folder))
5081
- # wrap each file in an <img> tag (you can special‑case videos if you like)
5082
- thumbs = []
5083
- for fn in files:
5084
- src = url_for('serve_media', filename=fn)
5085
- thumbs.append(f'<img src="{src}" alt="{fn}" style="max-width:150px; margin:5px;"/>')
5086
- if thumbs:
5087
- media_html = f'''
5088
- <section id="media-gallery" style="margin-top:20px;">
5089
- <h3>Media Gallery</h3>
5090
- <div style="display:flex; flex-wrap:wrap; gap:10px;">
5091
- {''.join(thumbs)}
5092
- </div>
5093
- </section>
5094
- '''
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
+ """
5095
5905
 
5096
- view_page_html = f"""
5097
- {head_html()}
5098
- {nav_html}
5099
- <div style=" width:100%; box-sizing:border-box; padding-top:5px;">
5100
- <div style="text-align:center; border:1px solid #ccc;
5101
- border-radius:8px; background-color:#f9f9f9;">
5102
- <div>{content}</div>
5103
- {media_html}
5104
- </div>
5105
- </div>
5106
- {footer_html()}
5107
- """
5108
- 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
5109
5926
 
5110
5927
 
5111
5928
  @smx.app.route('/docs')
@@ -5165,7 +5982,6 @@ def setup_routes(smx):
5165
5982
  html += "</table>"
5166
5983
  return html
5167
5984
 
5168
-
5169
5985
  @smx.app.route("/admin/chunks/edit/<int:chunk_id>", methods=["GET", "POST"])
5170
5986
  def edit_chunk(chunk_id):
5171
5987
  if request.method == "POST":
@@ -5200,99 +6016,237 @@ def setup_routes(smx):
5200
6016
  def edit_page(page_name):
5201
6017
  if request.method == "POST":
5202
6018
  new_page_name = request.form.get("page_name", "").strip()
5203
- 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
+
5204
6022
  if page_name in smx.pages and new_page_name:
5205
6023
  db.update_page(page_name, new_page_name, new_content)
6024
+ smx.pages = db.get_pages()
5206
6025
  return redirect(url_for("admin_panel"))
5207
- # Load the full content for the page to be edited.
5208
- content = smx.pages.get(page_name, "")
5209
- return render_template_string("""
5210
- <!DOCTYPE html>
5211
- <html>
5212
- <head>
5213
- <meta charset="UTF-8">
5214
- <title>Edit Page - {{ page_name }}</title>
5215
- <style>
5216
- body {
5217
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
5218
- background: #f4f7f9;
5219
- padding: 20px;
5220
- }
5221
- .editor {
5222
- max-width: 800px;
5223
- margin: 0 auto;
5224
- background: #fff;
5225
- padding: 20px;
5226
- border-radius: 8px;
5227
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
5228
- }
5229
- input, textarea {
5230
- width: 100%;
5231
- margin: 10px 0;
5232
- padding: 10px;
5233
- border: 1px solid #ccc;
5234
- border-radius: 4px;
5235
- }
5236
- button {
5237
- padding: 10px 20px;
5238
- background: #007acc;
5239
- border: none;
5240
- color: #fff;
5241
- border-radius: 4px;
5242
- cursor: pointer;
5243
- }
5244
- button:hover {
5245
- background: #005fa3;
5246
- }
5247
- a.button {
5248
- padding: 10px 20px;
5249
- background: #aaa;
5250
- border: none;
5251
- color: #fff;
5252
- border-radius: 4px;
5253
- text-decoration: none;
5254
- }
5255
- a.button:hover {
5256
- background: #888;
5257
- }
5258
- </style>
5259
- </head>
5260
- <body>
5261
- <div class="editor">
5262
- <h1>Edit Page - {{ page_name }}</h1>
5263
- <form method="post">
5264
- <input type="text" name="page_name" value="{{ page_name }}" required>
5265
- <textarea name="page_content" rows="20">{{ content }}</textarea>
5266
- <div style="margin-top:15px;">
5267
- <button type="submit">Update Page</button>
5268
- <a class="button" href="{{ url_for('admin_panel') }}">Cancel</a>
5269
- </div>
5270
- </form>
5271
- </div>
5272
- </body>
5273
- </html>
5274
- """, 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
+ )
5275
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
+
5276
6225
  # ────────────────────────────────────────────────────
5277
6226
  # ACCOUNTS
5278
6227
  # ────────────────────────────────────────────────────
5279
6228
  # ----Register ---------------------------------------
5280
6229
  @smx.app.route("/register", methods=["GET", "POST"])
5281
6230
  def register():
6231
+
6232
+ # If the consumer app has not enabled registration, redirect to login.
6233
+ if not getattr(smx, "registration_enabled", False):
6234
+ return redirect(url_for("login"))
6235
+
5282
6236
  if request.method == "POST":
5283
- email = request.form["email"].strip()
5284
- username = request.form["username"].strip()
5285
- password = request.form["password"]
5286
- role = request.form.get("role", "user")
5287
- if not email or not password:
5288
- flash("email and password required.")
5289
- else:
5290
- success = register_user(email, username, password, role)
5291
- if success:
5292
- flash("Registration successful—please log in.")
5293
- return redirect(url_for("login"))
5294
- else:
5295
- flash("Email already taken.")
6237
+ email = request.form["email"].strip()
6238
+ username = request.form["username"].strip()
6239
+ password = request.form["password"]
6240
+ role = request.form.get("role", "user")
6241
+ if not email or not password:
6242
+ flash("email and password required.")
6243
+ else:
6244
+ success = register_user(email, username, password, role)
6245
+ if success:
6246
+ flash("Registration successful—please log in.")
6247
+ return redirect(url_for("login"))
6248
+ else:
6249
+ flash("Email already taken.")
5296
6250
  return render_template("register.html")
5297
6251
 
5298
6252
  # ----- Login --------------------------------------------
@@ -5309,8 +6263,15 @@ def setup_routes(smx):
5309
6263
  session["username"] = user["username"]
5310
6264
  session["role"] = user["role"]
5311
6265
 
5312
- # ensure the just-logged-in user’s “Current” chat is closed on next GET
5313
- # session["needs_end_chat"] = True
6266
+ # If this account was created with a temporary password,
6267
+ # force them through the change-password flow first.
6268
+ if _auth.user_must_reset_password(user["id"]):
6269
+ session["must_reset_password"] = True
6270
+ flash("Please set a new password before continuing.", "warning")
6271
+ return redirect(url_for("change_password"))
6272
+
6273
+ # Clear any stale flag for accounts that no longer need a reset
6274
+ session.pop("must_reset_password", None)
5314
6275
 
5315
6276
  # — Load past chats from chats.db for this user —
5316
6277
  chat_ids = SQLHistoryStore.list_chats(user["id"])
@@ -5341,7 +6302,40 @@ def setup_routes(smx):
5341
6302
  flash("Invalid username or password.")
5342
6303
  return render_template("login.html")
5343
6304
 
5344
- # ----- Logout -------------------------------------------
6305
+
6306
+ @smx.app.route("/change_password", methods=["GET", "POST"])
6307
+ @login_required
6308
+ def change_password():
6309
+ user_id = session.get("user_id")
6310
+ if not user_id:
6311
+ flash("Please log in again.", "error")
6312
+ return redirect(url_for("login"))
6313
+
6314
+ if request.method == "POST":
6315
+ current = (request.form.get("current_password") or "").strip()
6316
+ new1 = (request.form.get("new_password") or "").strip()
6317
+ new2 = (request.form.get("confirm_password") or "").strip()
6318
+
6319
+ if not new1:
6320
+ flash("New password is required.", "error")
6321
+ elif new1 != new2:
6322
+ flash("New passwords do not match.", "error")
6323
+ elif not _auth.verify_password(user_id, current):
6324
+ flash("Current password is incorrect.", "error")
6325
+ else:
6326
+ # Update password + clear the mandatory-reset flag
6327
+ _auth.update_password(user_id, new1)
6328
+ _auth.clear_must_reset(user_id)
6329
+ session.pop("must_reset_password", None)
6330
+ flash("Password updated successfully.", "success")
6331
+
6332
+ next_url = request.args.get("next") or url_for("dashboard")
6333
+ return redirect(next_url)
6334
+
6335
+ return render_template("change_password.html")
6336
+
6337
+
6338
+ # ----- Logout -------------------------------------------
5345
6339
  @smx.app.route("/logout", methods=["POST"])
5346
6340
  def logout():
5347
6341
  """Clear session and return to login."""
@@ -5367,26 +6361,240 @@ def setup_routes(smx):
5367
6361
  return any(r in ("admin", "superadmin") for r in roles if r)
5368
6362
  return dict(can_see_admin=can_see_admin)
5369
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
+
5370
6552
  # --- UPLOAD MEDIA --------------------------------------
5371
6553
  @smx.app.route("/admin/upload_media", methods=["POST"])
5372
- def upload_media():
5373
- # Retrieve uploaded media files (images, videos, etc.).
6554
+ def upload_media():
5374
6555
  uploaded_files = request.files.getlist("media_files")
5375
6556
  file_paths = []
6557
+
5376
6558
  for file in uploaded_files:
5377
- if file.filename:
5378
- 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)
5379
6574
  file.save(filepath)
5380
- # This path can be copied by the developer. Adjust if you have a web server serving these files.
5381
- 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
+
5382
6582
  return jsonify({"file_paths": file_paths})
5383
6583
 
6584
+
5384
6585
  # Serve the raw media files
5385
6586
  @smx.app.route('/uploads/media/<path:filename>')
5386
6587
  def serve_media(filename):
5387
6588
  media_dir = os.path.join(_CLIENT_DIR, 'uploads', 'media')
5388
6589
  return send_from_directory(media_dir, filename)
5389
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
+
5390
6598
  # ────────────────────────────────────────────────────────────────────────────────────────
5391
6599
  # DASHBOARD
5392
6600
  # ────────────────────────────────────────────────────────────────────────────────────────
@@ -5398,104 +6606,7 @@ def setup_routes(smx):
5398
6606
 
5399
6607
  max_rows = 5000
5400
6608
  max_cols = 80
5401
-
5402
- def _smx_repair_python_cell(py_code: str) -> str:
5403
-
5404
- _CELL_REPAIR_RULES = """
5405
- You are an experienced Python code reviewer
5406
- Fix the Python cell to satisfy:
5407
- - Single valid cell; imports at the top.
5408
- - Do not import or invoke or use 'python-dotenv' or 'dotenv' because it's not needed.
5409
- - No top-level statements between if/elif/else branches.
5410
- - Regression must use either sklearn with train_test_split (then X_test exists) and R^2/MAE/RMSE,
5411
- or statsmodels OLS. No accuracy_score in regression.
5412
- - Keep all plotting + savefig + BytesIO + display inside the branch that created the figure.
5413
- - Return ONLY the corrected cell.
5414
- """
5415
- code = textwrap.dedent(py_code or "").strip()
5416
- needs_fix = False
5417
- if re.search(r"\baccuracy_score\b", code) and re.search(r"\bLinearRegression\b|\bOLS\b", code):
5418
- needs_fix = True
5419
- if re.search(r"\bX_test\b", code) and not re.search(r"\bX_test\s*=", code):
5420
- needs_fix = True
5421
- try:
5422
- ast.parse(code)
5423
- except SyntaxError:
5424
- needs_fix = True
5425
- if not needs_fix:
5426
- return code
5427
-
5428
- _prompt = f"```python\n{code}\n```"
5429
-
5430
- prof = _prof.get_profile("classification") or _prof.get_profile("admin")
5431
- if not prof:
5432
- return code
5433
-
5434
- prof["client"] = _prof.get_client(prof)
5435
- _client = prof["client"]
5436
- _model = prof["model"]
5437
- _provider = prof["provider"].lower()
5438
-
5439
- #1 Google
5440
- if _provider == "google":
5441
- from google.genai import types
5442
-
5443
- fixed = _client.models.generate_content(
5444
- model=_model,
5445
- contents=_prompt,
5446
- config=types.GenerateContentConfig(
5447
- system_instruction=_CELL_REPAIR_RULES,
5448
- temperature=0.8,
5449
- max_output_tokens=1024,
5450
- ),
5451
- )
5452
-
5453
- #2 Openai
5454
- elif _provider == "openai" and _model in GPT_MODELS_LATEST:
5455
-
5456
- args = set_args(
5457
- model=_model,
5458
- instructions=_CELL_REPAIR_RULES,
5459
- input=[{"role": "user", "content": _prompt}],
5460
- previous_id=None,
5461
- store=False,
5462
- reasoning_effort="medium",
5463
- verbosity="medium",
5464
- )
5465
- fixed = _out(_client.responses.create(**args))
5466
-
5467
- # Anthropic
5468
- elif _provider == "anthropic":
5469
-
5470
- fixed = _client.messages.create(
5471
- model=_model,
5472
- max_tokens=1024,
5473
- system=_CELL_REPAIR_RULES,
5474
- messages=[{"role": "user", "content":_prompt}],
5475
- stream=False,
5476
- )
5477
-
5478
- # OpenAI SDK
5479
- else:
5480
- fixed = _client.chat.completions.create(
5481
- model=_model,
5482
- messages=[
5483
- {"role": "system", "content":_CELL_REPAIR_RULES},
5484
- {"role": "user", "content":_prompt},
5485
- ],
5486
- max_tokens=1024,
5487
- )
5488
-
5489
- fixed_txt = clean_llm_code(fixed)
5490
-
5491
- try:
5492
- # Only accept the repaired cell if it's valid Python
5493
- ast.parse(fixed_txt)
5494
- return fixed_txt
5495
- except Exception:
5496
- # If the repaired version is still broken, fall back to the original code
5497
- return code
5498
-
6609
+
5499
6610
  section = request.args.get("section", "explore")
5500
6611
  datasets = [f for f in os.listdir(DATA_FOLDER) if f.lower().endswith(".csv")]
5501
6612
  selected_dataset = request.form.get("dataset") or request.args.get("dataset")
@@ -5532,6 +6643,8 @@ def setup_routes(smx):
5532
6643
  eda_df = df
5533
6644
  llm_usage = None
5534
6645
 
6646
+ TOKENS = {}
6647
+
5535
6648
  if request.method == "POST" and "askai_question" in request.form:
5536
6649
  askai_question = request.form["askai_question"].strip()
5537
6650
  if df is not None:
@@ -5547,55 +6660,95 @@ def setup_routes(smx):
5547
6660
  columns_summary = ", ".join(df.columns.tolist())
5548
6661
  dataset_context = f"columns: {columns_summary}"
5549
6662
  dataset_profile = f"modality: tabular; columns: {columns_summary}"
5550
-
5551
- refined_question = refine_question_agent(askai_question, dataset_context)
5552
- tags = classify_ml_job_agent(refined_question, dataset_profile)
5553
-
5554
- ai_code = smx.ai_generate_code(refined_question, tags, df)
5555
- llm_usage = smx.get_last_llm_usage()
5556
- ai_code = auto_inject_template(ai_code, tags, df)
5557
-
5558
- # --- 1) Strip dotenv ASAP (kill imports, %magics, !pip) ---
5559
- ctx = {
5560
- "question": refined_question,
5561
- "df_columns": list(df.columns),
5562
- }
5563
- 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
5564
6674
 
5565
- # --- 2) Domain/Plotting patches ---
5566
- ai_code = fix_scatter_and_summary(ai_code)
5567
- ai_code = fix_importance_groupby(ai_code)
5568
- ai_code = inject_auto_preprocessing(ai_code)
5569
- ai_code = patch_plot_code(ai_code, df, refined_question)
5570
- ai_code = ensure_matplotlib_title(ai_code)
5571
- ai_code = patch_pie_chart(ai_code, df, refined_question)
5572
- ai_code = patch_pairplot(ai_code, df)
5573
- ai_code = fix_seaborn_boxplot_nameerror(ai_code)
5574
- ai_code = fix_seaborn_barplot_nameerror(ai_code)
5575
- ai_code = get_plotting_imports(ai_code)
5576
- ai_code = patch_prefix_seaborn_calls(ai_code)
5577
- ai_code = patch_fix_sentinel_plot_calls(ai_code)
5578
- ai_code = patch_ensure_seaborn_import(ai_code)
5579
- ai_code = patch_rmse_calls(ai_code)
5580
- ai_code = patch_fix_seaborn_palette_calls(ai_code)
5581
- ai_code = patch_quiet_specific_warnings(ai_code)
5582
- ai_code = clean_llm_code(ai_code)
5583
- ai_code = ensure_image_output(ai_code)
5584
- ai_code = ensure_accuracy_block(ai_code)
5585
- ai_code = ensure_output(ai_code)
5586
- ai_code = fix_plain_prints(ai_code)
5587
- ai_code = fix_print_html(ai_code)
5588
- 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)
5589
6699
 
5590
- # --- 3-4) Global syntax/data fixers (must run AFTER patches, BEFORE final repair) ---
5591
- ai_code = ToolRunner(SYNTAX_AND_REPAIR).run(ai_code, ctx)
5592
-
5593
- # # --- 4) Final catch-all repair (run LAST) ---
5594
- ai_code = _smx_repair_python_cell(ai_code)
5595
- ai_code = harden_ai_code(ai_code)
5596
- ai_code = drop_bad_classification_metrics(ai_code, df)
5597
- ai_code = patch_fix_sentinel_plot_calls(ai_code)
5598
-
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
+
5599
6752
  # Always make sure 'df' is in the kernel before running user code
5600
6753
  df_init_code = (
5601
6754
  f"import pandas as pd\n"
@@ -5649,7 +6802,6 @@ def setup_routes(smx):
5649
6802
  "<meta charset='utf-8'>"
5650
6803
  "<title>Result</title>"
5651
6804
  "<style>"
5652
- " body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 42px; padding:24 32; }"
5653
6805
  " img { max-width: 100%; height: auto; }"
5654
6806
  " table { border-collapse: collapse; margin: 16px 0; }"
5655
6807
  " th, td { border: 1px solid #ddd; padding: 6px 10px; }"
@@ -5832,14 +6984,34 @@ def setup_routes(smx):
5832
6984
 
5833
6985
  # 3) Data Preview
5834
6986
  preview_cols = df.columns
5835
- preview_df = _coerce_intlike_for_display(df[preview_cols].head(8))
6987
+
6988
+ head_df = _coerce_intlike_for_display(df[preview_cols].head(8))
5836
6989
  data_cells.append({
5837
- "title": "Data Preview",
5838
- "output": Markup(datatable_box(preview_df)),
6990
+ "title": "Dataset Head",
6991
+ "output": Markup(datatable_box(head_df)),
5839
6992
  "code": f"df[{list(preview_cols)}].head(8)",
5840
6993
  "span": "eda-col-6"
5841
6994
  })
5842
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
+
5843
7015
  # 4) Summary Statistics
5844
7016
  summary_cols = df.columns
5845
7017
  summary_df = _coerce_intlike_for_display(df[summary_cols].describe())
@@ -6193,7 +7365,7 @@ def setup_routes(smx):
6193
7365
  "})\\n"
6194
7366
  "missing_df[missing_df['Missing Values'] > 0]"
6195
7367
  ),
6196
- "span":"eda-col-4"
7368
+ "span":"eda-col-3"
6197
7369
  })
6198
7370
 
6199
7371
  # 9) Missingness (Top 20) – Plotly bar chart
@@ -6432,7 +7604,7 @@ def setup_routes(smx):
6432
7604
  "vc = s.value_counts(dropna=False)\n"
6433
7605
  "top_k = 8 # Top-8 + Other (+ Missing)\n"
6434
7606
  ),
6435
- "span": "eda-col-4"
7607
+ "span": "eda-col-5"
6436
7608
  })
6437
7609
  else:
6438
7610
  data_cells.append({
@@ -6453,7 +7625,8 @@ def setup_routes(smx):
6453
7625
  cell["highlighted_code"] = Markup(_pygmentize(cell["code"]))
6454
7626
 
6455
7627
  highlighted_ai_code = _pygmentize(ai_code)
6456
-
7628
+ smxAI = "smx-Orion"
7629
+
6457
7630
  return render_template(
6458
7631
  "dashboard.html",
6459
7632
  section=section,
@@ -6465,9 +7638,10 @@ def setup_routes(smx):
6465
7638
  askai_question=smx.sanitize_rough_to_markdown_task(askai_question),
6466
7639
  refined_question=refined_question,
6467
7640
  tasks=tags,
7641
+ smxAI=smxAI,
6468
7642
  data_cells=data_cells,
6469
7643
  session_id=session_id,
6470
- llm_usage=llm_usage
7644
+ TOKENS=TOKENS
6471
7645
  )
6472
7646
 
6473
7647
 
@@ -6528,6 +7702,179 @@ def setup_routes(smx):
6528
7702
  # go back to the dashboard; dashboard() will auto-select the next file
6529
7703
  return redirect(url_for("dashboard"))
6530
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
+
6531
7878
 
6532
7879
  def _pdf_fallback_reportlab(full_html: str):
6533
7880
  """ReportLab fallback: extract text + base64 <img> and lay them out."""
@@ -6621,4 +7968,5 @@ def setup_routes(smx):
6621
7968
  {footer}
6622
7969
  </body>
6623
7970
  </html>
6624
- """, error_message=str(e)), 500
7971
+ """, error_message=str(e)), 500
7972
+