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