syntaxmatrix 2.5.5.5__py3-none-any.whl → 2.6.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- syntaxmatrix/__init__.py +3 -2
- syntaxmatrix/agentic/agents.py +1220 -169
- syntaxmatrix/agentic/agents_orchestrer.py +326 -0
- syntaxmatrix/agentic/code_tools_registry.py +27 -32
- syntaxmatrix/auth.py +142 -5
- syntaxmatrix/commentary.py +16 -16
- syntaxmatrix/core.py +192 -84
- 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 +670 -0
- syntaxmatrix/profiles.py +28 -10
- syntaxmatrix/routes.py +1941 -593
- 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/change_password.html +124 -0
- syntaxmatrix/templates/dashboard.html +296 -131
- syntaxmatrix/templates/dataset_resize.html +535 -0
- syntaxmatrix/templates/edit_page.html +2535 -0
- syntaxmatrix/utils.py +2728 -2835
- {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/METADATA +6 -2
- {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/RECORD +42 -25
- syntaxmatrix/generate_page.py +0 -634
- syntaxmatrix/static/icons/hero_bg.jpg +0 -0
- {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/WHEEL +0 -0
- {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/licenses/LICENSE.txt +0 -0
- {syntaxmatrix-2.5.5.5.dist-info → syntaxmatrix-2.6.2.dist-info}/top_level.txt +0 -0
syntaxmatrix/routes.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
|
|
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,13 +29,32 @@ from syntaxmatrix.history_store import SQLHistoryStore, PersistentHistoryStore
|
|
|
32
29
|
from syntaxmatrix.kernel_manager import SyntaxMatrixKernelManager, execute_code_in_kernel
|
|
33
30
|
from syntaxmatrix.vector_db import *
|
|
34
31
|
from syntaxmatrix.settings.string_navbar import string_navbar_items
|
|
35
|
-
from syntaxmatrix.settings.model_map import GPT_MODELS_LATEST, PROVIDERS_MODELS, MODEL_DESCRIPTIONS, PURPOSE_TAGS, EMBEDDING_MODELS
|
|
36
32
|
from syntaxmatrix.project_root import detect_project_root
|
|
37
|
-
from syntaxmatrix import
|
|
38
|
-
from syntaxmatrix import auth as _auth
|
|
33
|
+
from syntaxmatrix.page_builder_defaults import make_default_layout, layout_to_html
|
|
34
|
+
from syntaxmatrix import auth as _auth
|
|
35
|
+
from syntaxmatrix.auth import register_user, authenticate, login_required, admin_required, superadmin_required, update_password
|
|
39
36
|
from syntaxmatrix import profiles as _prof
|
|
40
37
|
from syntaxmatrix.gpt_models_latest import set_args, extract_output_text as _out
|
|
41
|
-
from syntaxmatrix.agentic.
|
|
38
|
+
from syntaxmatrix.agentic.agent_tools import ToolRunner
|
|
39
|
+
from syntaxmatrix.settings.model_map import(
|
|
40
|
+
GPT_MODELS_LATEST,
|
|
41
|
+
PROVIDERS_MODELS,
|
|
42
|
+
MODEL_DESCRIPTIONS,
|
|
43
|
+
PURPOSE_TAGS,
|
|
44
|
+
EMBEDDING_MODELS
|
|
45
|
+
)
|
|
46
|
+
from syntaxmatrix.agentic.agents import (
|
|
47
|
+
classify_ml_job_agent, context_compatibility,
|
|
48
|
+
agentic_generate_page,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
from syntaxmatrix.page_builder_generation import (
|
|
52
|
+
build_layout_for_page,
|
|
53
|
+
fill_layout_images_from_pixabay,
|
|
54
|
+
compile_layout_to_html,
|
|
55
|
+
patch_page_from_layout,
|
|
56
|
+
patch_section_titles_and_intros,
|
|
57
|
+
)
|
|
42
58
|
|
|
43
59
|
try:
|
|
44
60
|
from pygments import highlight as _hl
|
|
@@ -48,21 +64,11 @@ try:
|
|
|
48
64
|
except Exception:
|
|
49
65
|
_HAVE_PYGMENTS = False
|
|
50
66
|
|
|
51
|
-
# from syntaxmatrix.utils import *
|
|
52
|
-
from syntaxmatrix.utils import (
|
|
53
|
-
auto_inject_template, drop_bad_classification_metrics, ensure_accuracy_block,
|
|
54
|
-
ensure_image_output, ensure_output, fix_plain_prints, fix_print_html, patch_fix_sentinel_plot_calls,
|
|
55
|
-
patch_pairplot, fix_to_datetime_errors, harden_ai_code, patch_ensure_seaborn_import, get_plotting_imports,
|
|
56
|
-
patch_fix_seaborn_palette_calls, patch_quiet_specific_warnings, fix_seaborn_barplot_nameerror, fix_seaborn_boxplot_nameerror, ensure_matplotlib_title, patch_plot_code, patch_prefix_seaborn_calls, fix_scatter_and_summary, inject_auto_preprocessing, fix_importance_groupby, patch_pie_chart, patch_rmse_calls, clean_llm_code
|
|
57
|
-
)
|
|
58
|
-
|
|
59
|
-
from syntaxmatrix.agentic.agent_tools import ToolRunner
|
|
60
|
-
from syntaxmatrix.agentic.code_tools_registry import EARLY_SANITIZERS, SYNTAX_AND_REPAIR
|
|
61
|
-
|
|
62
67
|
_CLIENT_DIR = detect_project_root()
|
|
63
68
|
_stream_q = queue.Queue()
|
|
64
69
|
_stream_cancelled = {}
|
|
65
70
|
_last_result_html = {} # { session_id: html_doc }
|
|
71
|
+
_last_resized_csv = {} # { resize_id: bytes for last resized CSV per browser session }
|
|
66
72
|
|
|
67
73
|
# single, reused formatter: inline styles, padding, rounded corners, scroll
|
|
68
74
|
_FMT = _HtmlFmt(
|
|
@@ -120,6 +126,7 @@ def get_contrast_color(hex_color: str) -> str:
|
|
|
120
126
|
def render_chat_history(smx):
|
|
121
127
|
plottings_html = smx.get_plottings()
|
|
122
128
|
messages = smx.get_chat_history() or []
|
|
129
|
+
|
|
123
130
|
chat_html = ""
|
|
124
131
|
if not messages and not plottings_html:
|
|
125
132
|
chat_html += f"""
|
|
@@ -169,27 +176,43 @@ def setup_routes(smx):
|
|
|
169
176
|
os.makedirs(DATA_FOLDER, exist_ok=True)
|
|
170
177
|
|
|
171
178
|
MEDIA_FOLDER = os.path.join(_CLIENT_DIR, "uploads", "media")
|
|
172
|
-
|
|
173
|
-
|
|
179
|
+
|
|
180
|
+
# Ensure media subfolders (images/videos + generated)
|
|
181
|
+
MEDIA_IMAGES_UPLOADED = os.path.join(MEDIA_FOLDER, "images", "uploaded")
|
|
182
|
+
MEDIA_IMAGES_GENERATED = os.path.join(MEDIA_FOLDER, "images", "generated")
|
|
183
|
+
MEDIA_IMAGES_GENERATED_ICONS = os.path.join(MEDIA_IMAGES_GENERATED, "icons")
|
|
184
|
+
MEDIA_IMAGES_THUMBS = os.path.join(MEDIA_IMAGES_GENERATED, "thumbs")
|
|
185
|
+
|
|
186
|
+
MEDIA_VIDEOS_UPLOADED = os.path.join(MEDIA_FOLDER, "videos", "uploaded")
|
|
187
|
+
MEDIA_FILES_UPLOADED = os.path.join(MEDIA_FOLDER, "files", "uploaded")
|
|
188
|
+
|
|
189
|
+
for _p in [MEDIA_IMAGES_UPLOADED,
|
|
190
|
+
MEDIA_IMAGES_GENERATED,
|
|
191
|
+
MEDIA_IMAGES_GENERATED_ICONS,
|
|
192
|
+
MEDIA_IMAGES_THUMBS,
|
|
193
|
+
MEDIA_VIDEOS_UPLOADED,
|
|
194
|
+
MEDIA_FILES_UPLOADED,
|
|
195
|
+
]:
|
|
196
|
+
os.makedirs(_p, exist_ok=True)
|
|
174
197
|
|
|
175
198
|
def _evict_profile_caches_by_name(prof_name: str) -> None:
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
199
|
+
"""
|
|
200
|
+
Clear any in-memory profile cache on `smx` that points to the deleted profile.
|
|
201
|
+
Future-proof: it scans all attributes and clears any dict whose 'name' matches.
|
|
202
|
+
"""
|
|
203
|
+
if not prof_name:
|
|
204
|
+
return
|
|
205
|
+
try:
|
|
206
|
+
for attr in dir(smx):
|
|
207
|
+
# be generous: match anything that mentions 'profile' in its name
|
|
208
|
+
if "profile" not in attr.lower():
|
|
209
|
+
continue
|
|
210
|
+
val = getattr(smx, attr, None)
|
|
211
|
+
if isinstance(val, dict) and val.get("name") == prof_name:
|
|
212
|
+
setattr(smx, attr, {}) # drop just this one; others untouched
|
|
213
|
+
except Exception:
|
|
214
|
+
# never let cache eviction break the request path
|
|
215
|
+
pass
|
|
193
216
|
|
|
194
217
|
@smx.app.after_request
|
|
195
218
|
def _set_session_cookie(resp):
|
|
@@ -219,24 +242,30 @@ def setup_routes(smx):
|
|
|
219
242
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
220
243
|
<title>{smx.page}</title>
|
|
221
244
|
<style>
|
|
245
|
+
/* ----- HTML/BODY ----------------------------------- */
|
|
246
|
+
html {{
|
|
247
|
+
font-size: clamp(12px, 1.7vw, 18px);
|
|
248
|
+
/* scrollbar-gutter: stable both-edges; */
|
|
249
|
+
}}
|
|
222
250
|
body {{
|
|
223
|
-
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
224
|
-
margin: 0 20px;
|
|
225
251
|
padding: 0;
|
|
252
|
+
margin: 0;
|
|
226
253
|
background: {smx.theme["background"]};
|
|
227
254
|
color: {smx.theme["text_color"]};
|
|
228
255
|
}}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
256
|
+
html, body {{ scroll-behavior: auto; }}
|
|
257
|
+
.admin-grid, .admin-shell .card {{ min-width: 0; }}
|
|
258
|
+
html, body, .admin-shell {{ overflow-x: visible !important; }}
|
|
259
|
+
</style>
|
|
260
|
+
<style>
|
|
261
|
+
/* ----- NAVBAR -------------------------------- */
|
|
233
262
|
/* Desktop Navbar */
|
|
234
263
|
nav {{
|
|
235
264
|
display: flex;
|
|
236
265
|
justify-content: space-between;
|
|
237
266
|
align-items: center;
|
|
238
267
|
background: {smx.theme["nav_background"]};
|
|
239
|
-
padding: 10px
|
|
268
|
+
padding: 10px 24px;
|
|
240
269
|
position: fixed;
|
|
241
270
|
top: 0;
|
|
242
271
|
left: 0;
|
|
@@ -246,19 +275,33 @@ def setup_routes(smx):
|
|
|
246
275
|
.nav-left {{
|
|
247
276
|
display: flex;
|
|
248
277
|
align-items: center;
|
|
249
|
-
}}
|
|
250
|
-
.nav-left .logo {{
|
|
251
|
-
font-size: clamp(1.3rem, 2vw, 1.5rem);
|
|
252
|
-
font-weight: bold;
|
|
253
278
|
color: {smx.theme["nav_text"]};
|
|
254
|
-
|
|
279
|
+
gap: 8px;
|
|
255
280
|
}}
|
|
256
|
-
.nav-left .
|
|
257
|
-
|
|
281
|
+
.nav-left .logo {{
|
|
282
|
+
align-items: center;
|
|
283
|
+
font-weight: bold;
|
|
284
|
+
font-size: clamp(1.4rem, 1.8vw, 1.8rem);
|
|
285
|
+
margin-right: 0;
|
|
286
|
+
}}
|
|
287
|
+
|
|
288
|
+
.nav-left a {{
|
|
258
289
|
color: {smx.theme["nav_text"]};
|
|
259
290
|
text-decoration: none;
|
|
260
291
|
margin-right: 15px;
|
|
261
292
|
}}
|
|
293
|
+
|
|
294
|
+
.nav-left .nav-links a.active,
|
|
295
|
+
.nav-left .nav-links a.active:hover,
|
|
296
|
+
#mobile-nav a.active,
|
|
297
|
+
#mobile-nav a.active:hover {{
|
|
298
|
+
background-color: var(--nav-bg) !important; /* keep the same base */
|
|
299
|
+
box-shadow: inset 0 0 0 9999px rgba(0,0,0,.52); /* darken ~52% */
|
|
300
|
+
border-radius: 6px;
|
|
301
|
+
padding: 2px 8px;
|
|
302
|
+
color:cyan;
|
|
303
|
+
}}
|
|
304
|
+
|
|
262
305
|
.nav-right a {{
|
|
263
306
|
font-size: clamp(1rem, 1.2vw, 1.2rem);
|
|
264
307
|
color: {smx.theme["nav_text"]};
|
|
@@ -266,9 +309,13 @@ def setup_routes(smx):
|
|
|
266
309
|
}}
|
|
267
310
|
/* Hamburger button (hidden on desktop) */
|
|
268
311
|
#hamburger-btn {{
|
|
269
|
-
display: none;
|
|
270
|
-
width:
|
|
312
|
+
display: none; /* shown only in mobile media query */
|
|
313
|
+
width: auto;
|
|
314
|
+
height: 40px;
|
|
315
|
+
margin-left: auto; /* push it to the far right */
|
|
316
|
+
padding: 0;
|
|
271
317
|
font-size: 2rem;
|
|
318
|
+
line-height: 1;
|
|
272
319
|
background: none;
|
|
273
320
|
border: none;
|
|
274
321
|
color: {smx.theme["nav_text"]};
|
|
@@ -289,7 +336,7 @@ def setup_routes(smx):
|
|
|
289
336
|
display: flex;
|
|
290
337
|
flex-direction: column;
|
|
291
338
|
gap: 10px;
|
|
292
|
-
z-index:
|
|
339
|
+
z-index: 1000;
|
|
293
340
|
color: {mobile_text_color};
|
|
294
341
|
}}
|
|
295
342
|
#mobile-nav a {{
|
|
@@ -308,24 +355,29 @@ def setup_routes(smx):
|
|
|
308
355
|
/* Responsive adjustments for mobile */
|
|
309
356
|
@media (max-width: 768px) {{
|
|
310
357
|
.nav-left .nav-links, .nav-right {{
|
|
311
|
-
|
|
358
|
+
display: none;
|
|
312
359
|
}}
|
|
313
360
|
#hamburger-btn {{
|
|
314
361
|
display: block;
|
|
315
362
|
}}
|
|
316
|
-
|
|
363
|
+
body {{
|
|
364
|
+
padding: 0 10px;
|
|
365
|
+
}}
|
|
317
366
|
}}
|
|
318
|
-
|
|
367
|
+
</style>
|
|
368
|
+
|
|
369
|
+
<style>
|
|
370
|
+
/* ----- SIDEBAR ---------------------------------------------------------- */
|
|
319
371
|
#sidebar {{
|
|
320
372
|
position: fixed;
|
|
321
373
|
top: 40px;
|
|
322
|
-
left: -
|
|
374
|
+
left: -260px;
|
|
323
375
|
width: var(--sidebar-w);
|
|
324
|
-
height: calc(100% -
|
|
376
|
+
height: calc(100% - 2px);
|
|
325
377
|
background: {smx.theme["sidebar_background"]};
|
|
326
378
|
overflow-y: auto;
|
|
327
379
|
padding: 10px; 5px;
|
|
328
|
-
font-size: 1.2rem;
|
|
380
|
+
font-size: clamp(1.2rem, 1.4vw, 1.6rem);
|
|
329
381
|
gap: 10px;
|
|
330
382
|
box-shadow: 2px 0 5px rgba(0,0,0,0.3);
|
|
331
383
|
transition: left 0.3s ease;
|
|
@@ -334,7 +386,7 @@ def setup_routes(smx):
|
|
|
334
386
|
}}
|
|
335
387
|
#sidebar a {{
|
|
336
388
|
color: {get_contrast_color(smx.theme["sidebar_background"])};
|
|
337
|
-
|
|
389
|
+
padding:3px;
|
|
338
390
|
text-decoration: none;
|
|
339
391
|
}}
|
|
340
392
|
#sidebar.open {{
|
|
@@ -359,18 +411,28 @@ def setup_routes(smx):
|
|
|
359
411
|
background-color: rgba(0, 0, 0, 0.05);
|
|
360
412
|
transform: scale(1.2);
|
|
361
413
|
}}
|
|
414
|
+
</style>
|
|
415
|
+
<style>
|
|
416
|
+
/* ----- CHAT HISTORY ---------------------------------------------------- */
|
|
362
417
|
#chat-history {{
|
|
363
418
|
width: 100%;
|
|
364
419
|
max-width: 980px;
|
|
365
|
-
margin: 50px auto 10px auto;
|
|
366
|
-
padding: 10px 5px;
|
|
367
420
|
background: {smx.theme["chat_background"]};
|
|
368
421
|
border-radius: 20px;
|
|
369
422
|
overflow-y: auto;
|
|
370
423
|
min-height: 360px;
|
|
424
|
+
margin: 50px auto 10px auto;
|
|
425
|
+
padding: 10px 5px 0 5px;
|
|
426
|
+
padding-bottom: calc(var(--composer-h, 104px) + 78);
|
|
371
427
|
}}
|
|
428
|
+
#chat-history .chat-message {{
|
|
429
|
+
scroll-margin-bottom: calc(var(--composer-h, 104px) + 78);
|
|
430
|
+
}}
|
|
431
|
+
#chat-history, #widget-container {{ overflow-anchor: none; }}
|
|
432
|
+
|
|
372
433
|
#chat-history-default {{
|
|
373
434
|
width: 100%;
|
|
435
|
+
max-width: 100%;
|
|
374
436
|
margin: 45px auto 10px auto;
|
|
375
437
|
padding: 10px 5px;
|
|
376
438
|
background: {smx.theme["chat_background"]};
|
|
@@ -384,13 +446,13 @@ def setup_routes(smx):
|
|
|
384
446
|
transform:scale(1.2);
|
|
385
447
|
transition: all 0.3s ease;
|
|
386
448
|
}}
|
|
449
|
+
|
|
450
|
+
{ _chat_css() }
|
|
451
|
+
|
|
387
452
|
#widget-container {{
|
|
388
|
-
max-width:
|
|
453
|
+
max-width: 100%;
|
|
389
454
|
margin: 0 auto 40px auto;
|
|
390
455
|
}}
|
|
391
|
-
|
|
392
|
-
{ _chat_css() }
|
|
393
|
-
|
|
394
456
|
.closeable-div {{
|
|
395
457
|
position: relative;
|
|
396
458
|
padding: 20px;
|
|
@@ -410,27 +472,20 @@ def setup_routes(smx):
|
|
|
410
472
|
.close-btn:hover {{
|
|
411
473
|
color: #ff0000;
|
|
412
474
|
}}
|
|
413
|
-
|
|
414
|
-
<style>
|
|
475
|
+
|
|
415
476
|
@keyframes spin {{
|
|
416
477
|
0% {{ transform: rotate(0deg); }}
|
|
417
478
|
100% {{ transform: rotate(360deg); }}
|
|
418
479
|
}}
|
|
419
|
-
|
|
420
|
-
</style>
|
|
421
|
-
<style>
|
|
422
480
|
.dropdown:hover .dropdown-content {{
|
|
423
481
|
display: block;
|
|
424
482
|
}}
|
|
425
|
-
</style>
|
|
426
|
-
<style>
|
|
427
483
|
/* Keep the shift amount equal to the actual sidebar width */
|
|
428
484
|
:root {{ --sidebar-w: 16vw; --nav-bg: {{smx.theme["nav_background"]}}; }}
|
|
429
485
|
|
|
430
486
|
/* Messages slide; composer doesn't stay shifted */
|
|
431
|
-
#chat-history,
|
|
432
|
-
|
|
433
|
-
|
|
487
|
+
#chat-history, #widget-container {{ transition: transform .45s ease; }}
|
|
488
|
+
|
|
434
489
|
/* Messages move fully clear of the sidebar */
|
|
435
490
|
body.sidebar-open #chat-history {{ transform: translateX(calc(var(--sidebar-w) * 0.30)); }}
|
|
436
491
|
|
|
@@ -447,56 +502,28 @@ def setup_routes(smx):
|
|
|
447
502
|
#widget-container, #smx-widgets {{
|
|
448
503
|
position: sticky;
|
|
449
504
|
bottom: 0;
|
|
450
|
-
z-index: 1100;
|
|
505
|
+
z-index: 1100;
|
|
451
506
|
background: inherit;
|
|
452
507
|
}}
|
|
453
|
-
|
|
454
|
-
padding-bottom: calc(var(--composer-h, 104px) + 78);
|
|
455
|
-
}}
|
|
456
|
-
#chat-history .chat-message {{
|
|
457
|
-
scroll-margin-bottom: calc(var(--composer-h, 104px) + 78);
|
|
458
|
-
}}
|
|
459
|
-
/* Stop browser scroll-anchoring from fighting our autoscroll */
|
|
460
|
-
#chat-history,
|
|
461
|
-
#widget-container {{
|
|
462
|
-
overflow-anchor: none;
|
|
463
|
-
}}
|
|
464
|
-
|
|
465
|
-
/* Reduce visual “jump” when the scrollbar appears/disappears */
|
|
466
|
-
html {{
|
|
467
|
-
scrollbar-gutter: stable both-edges;
|
|
468
|
-
}}
|
|
469
|
-
|
|
470
|
-
/* Avoid unexpected smooth scrolling that can look like a jerk */
|
|
471
|
-
html, body {{
|
|
472
|
-
scroll-behavior: auto;
|
|
473
|
-
}}
|
|
474
|
-
|
|
508
|
+
|
|
475
509
|
/* Textarea bounds */
|
|
476
510
|
.chat-composer {{ min-width:0; max-height:12vh; }}
|
|
477
|
-
@media (max-width:
|
|
511
|
+
@media (max-width:1200px){{
|
|
478
512
|
.chat-composer {{
|
|
479
513
|
min-height:56px;
|
|
480
514
|
line-height:1.4;
|
|
481
515
|
white-space: pre-wrap;
|
|
482
|
-
padding: 10px 10px 16px
|
|
483
|
-
font-size: 16px;
|
|
484
|
-
overflow-y: auto;
|
|
516
|
+
padding: 10px 10px 16px 12px;
|
|
517
|
+
font-size: 16px;
|
|
518
|
+
overflow-y: auto;
|
|
485
519
|
box-sizing: border-box;
|
|
486
520
|
}}
|
|
487
521
|
}}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
#mobile-nav a.active:hover {{
|
|
493
|
-
background-color: var(--nav-bg) !important; /* keep the same base */
|
|
494
|
-
box-shadow: inset 0 0 0 9999px rgba(0,0,0,.52); /* darken ~52% */
|
|
495
|
-
border-radius: 6px;
|
|
496
|
-
padding: 2px 8px;
|
|
497
|
-
color:cyan;
|
|
522
|
+
@media (max-width:900px){{
|
|
523
|
+
#chat-history {{
|
|
524
|
+
padding-top: 62px;
|
|
525
|
+
}}
|
|
498
526
|
}}
|
|
499
|
-
|
|
500
527
|
</style>
|
|
501
528
|
|
|
502
529
|
<!-- Add MathJax -->
|
|
@@ -513,7 +540,7 @@ def setup_routes(smx):
|
|
|
513
540
|
}});
|
|
514
541
|
</script>
|
|
515
542
|
<script>
|
|
516
|
-
|
|
543
|
+
// Turn the latest bot <p> into fade-in “lines” and reveal them sequentially
|
|
517
544
|
function splitToLines(node){{
|
|
518
545
|
// If there are list items, animate them item-by-item.
|
|
519
546
|
const lis = node.querySelectorAll('li');
|
|
@@ -591,32 +618,87 @@ def setup_routes(smx):
|
|
|
591
618
|
dst = (href or "/").rstrip("/") or "/"
|
|
592
619
|
return cur == dst or cur.startswith(dst + "/")
|
|
593
620
|
|
|
621
|
+
# Pull nav metadata from DB. Fail-soft if anything goes wrong.
|
|
622
|
+
try:
|
|
623
|
+
nav_meta = db.get_page_nav_map()
|
|
624
|
+
except Exception as e:
|
|
625
|
+
smx.warning(f"get_page_nav_map failed: {e}")
|
|
626
|
+
nav_meta = {}
|
|
627
|
+
|
|
628
|
+
def _page_label(name: str) -> str:
|
|
629
|
+
meta = nav_meta.get(name.lower()) or {}
|
|
630
|
+
label = (meta.get("nav_label") or "").strip()
|
|
631
|
+
return label or name.capitalize()
|
|
632
|
+
|
|
633
|
+
def _page_visible(name: str) -> bool:
|
|
634
|
+
meta = nav_meta.get(name.lower())
|
|
635
|
+
# Default behaviour: if there's no row, we show it.
|
|
636
|
+
if not meta:
|
|
637
|
+
return True
|
|
638
|
+
return bool(meta.get("show_in_nav", True))
|
|
639
|
+
|
|
640
|
+
def _page_order(name: str) -> int:
|
|
641
|
+
meta = nav_meta.get(name.lower()) or {}
|
|
642
|
+
order_val = meta.get("nav_order")
|
|
643
|
+
try:
|
|
644
|
+
return int(order_val)
|
|
645
|
+
except (TypeError, ValueError):
|
|
646
|
+
# Pages without explicit order go to the end, sorted by label
|
|
647
|
+
return 10_000
|
|
648
|
+
|
|
594
649
|
# Build nav links with active class
|
|
595
650
|
nav_items = []
|
|
596
|
-
|
|
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,10 +711,12 @@ def setup_routes(smx):
|
|
|
629
711
|
'</form>'
|
|
630
712
|
)
|
|
631
713
|
else:
|
|
714
|
+
reg_link = ""
|
|
715
|
+
if getattr(smx, "registration_enabled", False):
|
|
716
|
+
reg_link = f'|<a href="{url_for("register")}" class="nav-link">Register</a>'
|
|
632
717
|
auth_links = (
|
|
633
718
|
f'<a href="{url_for("login")}" class="nav-link">Login</a>'
|
|
634
|
-
'
|
|
635
|
-
f'<a href="{url_for("register")}" class="nav-link">Register</a>'
|
|
719
|
+
f'{reg_link}'
|
|
636
720
|
)
|
|
637
721
|
|
|
638
722
|
desktop_nav = f"""
|
|
@@ -664,7 +748,6 @@ def setup_routes(smx):
|
|
|
664
748
|
{hamburger_btn}
|
|
665
749
|
</nav>
|
|
666
750
|
{mobile_nav}
|
|
667
|
-
{hamburger_btn}
|
|
668
751
|
"""
|
|
669
752
|
|
|
670
753
|
def footer_html():
|
|
@@ -708,7 +791,7 @@ def setup_routes(smx):
|
|
|
708
791
|
.chat-message.user {{
|
|
709
792
|
background: #e4e8ed;
|
|
710
793
|
float: right;
|
|
711
|
-
margin-right:
|
|
794
|
+
margin-right: 20px;
|
|
712
795
|
border-top-right-radius: 2px;
|
|
713
796
|
}}
|
|
714
797
|
.chat-message.user::after {{
|
|
@@ -1501,33 +1584,7 @@ def setup_routes(smx):
|
|
|
1501
1584
|
@smx.app.route("/", methods=["GET", "POST"])
|
|
1502
1585
|
def home():
|
|
1503
1586
|
smx.page = ""
|
|
1504
|
-
|
|
1505
|
-
# session["current_session"] = {"id": str(uuid.uuid4()), "title": "Current", "history": []}
|
|
1506
|
-
# session.setdefault("past_sessions", [])
|
|
1507
|
-
# session.setdefault("chat_history", [])
|
|
1508
|
-
# session["active_chat_id"] = session["current_session"]["id"]
|
|
1509
|
-
|
|
1510
|
-
# if session.pop("needs_end_chat", False):
|
|
1511
|
-
# current_history = session.get("chat_history", [])
|
|
1512
|
-
# current_session = session.get("current_session", {"id": str(uuid.uuid4()), "title": "Current", "history": []})
|
|
1513
|
-
# past_sessions = session.get("past_sessions", [])
|
|
1514
|
-
|
|
1515
|
-
# generated_title = smx.generate_contextual_title(current_history)
|
|
1516
|
-
# current_session["title"] = generated_title
|
|
1517
|
-
# past_sessions.insert(0, {"id": current_session["id"], "title": current_session["title"]})
|
|
1518
|
-
|
|
1519
|
-
# session["past_sessions"] = past_sessions
|
|
1520
|
-
# session["current_session"] = {"id": str(uuid.uuid4()), "title": "Current", "history": []}
|
|
1521
|
-
# session["active_chat_id"] = session["current_session"]["id"]
|
|
1522
|
-
# session["chat_history"] = []
|
|
1523
|
-
|
|
1524
|
-
# session["user_query"] = ""
|
|
1525
|
-
# session["app_token"] = smx.app_token
|
|
1526
|
-
|
|
1527
|
-
# cur = session.get("current_session")
|
|
1528
|
-
# if cur and cur.get("id") and session.get("active_chat_id") != cur["id"]:
|
|
1529
|
-
# session["active_chat_id"] = cur["id"]
|
|
1530
|
-
|
|
1587
|
+
|
|
1531
1588
|
if not session.get("current_session"):
|
|
1532
1589
|
# metadata only: id + title
|
|
1533
1590
|
session["current_session"] = {"id": str(uuid.uuid4()), "title": "Current"}
|
|
@@ -2397,31 +2454,18 @@ def setup_routes(smx):
|
|
|
2397
2454
|
home_page_html = f"""
|
|
2398
2455
|
{head_html()}
|
|
2399
2456
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
2457
|
+
|
|
2400
2458
|
<style>
|
|
2401
|
-
/* Match /dashboard font scale */
|
|
2402
|
-
:root{{
|
|
2403
|
-
--smx-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
|
2404
|
-
Arial, "Noto Sans", "Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
|
|
2405
|
-
--smx-font-size: 16px; /* >=16px prevents iOS zoom */
|
|
2406
|
-
--smx-line: 1.55;
|
|
2407
|
-
}}
|
|
2408
|
-
html{{ -webkit-text-size-adjust: 100%; }}
|
|
2409
|
-
body{{ font-family: var(--smx-font); font-size: var(--smx-font-size); line-height: var(--smx-line); }}
|
|
2410
|
-
</style>
|
|
2411
|
-
<style>
|
|
2412
|
-
/* Container sizing & equal gutters */
|
|
2413
2459
|
.chat-container{{
|
|
2414
2460
|
max-width: 820px;
|
|
2415
2461
|
margin-inline: auto;
|
|
2416
2462
|
padding-inline: 12px;
|
|
2417
2463
|
box-sizing: border-box;
|
|
2418
2464
|
}}
|
|
2419
|
-
|
|
2420
|
-
/* Bubbles never overflow the viewport width */
|
|
2421
2465
|
.chat-messages{{
|
|
2422
2466
|
overflow-wrap: anywhere;
|
|
2423
2467
|
word-break: break-word;
|
|
2424
|
-
padding-bottom: 84px;
|
|
2468
|
+
padding-bottom: 84px;
|
|
2425
2469
|
}}
|
|
2426
2470
|
|
|
2427
2471
|
/* Sticky footer input area (safe on iOS address-bar) */
|
|
@@ -2466,8 +2510,7 @@ def setup_routes(smx):
|
|
|
2466
2510
|
@supports (-webkit-touch-callout: none){{
|
|
2467
2511
|
.chat-footer textarea{{ resize: none; }}
|
|
2468
2512
|
}}
|
|
2469
|
-
|
|
2470
|
-
<style>
|
|
2513
|
+
|
|
2471
2514
|
/* Desktop: push chat-history a little more than the base shift */
|
|
2472
2515
|
body.sidebar-open #chat-history{{
|
|
2473
2516
|
transform: translateX(calc(var(--sidebar-shift, var(--sidebar-w)) - 90px));
|
|
@@ -2499,10 +2542,9 @@ def setup_routes(smx):
|
|
|
2499
2542
|
}}
|
|
2500
2543
|
/* Typewriter look during streaming only */
|
|
2501
2544
|
.chat-message.bot.streaming .stream-target{{
|
|
2502
|
-
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
|
2503
2545
|
font-variant-ligatures: none;
|
|
2504
2546
|
white-space: pre-wrap; /* keep line breaks as they stream */
|
|
2505
|
-
letter-spacing: 0.
|
|
2547
|
+
letter-spacing: 0.02em; /* subtle spacing for “typed” feel */
|
|
2506
2548
|
}}
|
|
2507
2549
|
|
|
2508
2550
|
/* Blinking caret visible only while streaming */
|
|
@@ -2525,8 +2567,7 @@ def setup_routes(smx):
|
|
|
2525
2567
|
0%, 100% {{ opacity: 0; }}
|
|
2526
2568
|
50% {{ opacity: 1; }}
|
|
2527
2569
|
}}
|
|
2528
|
-
|
|
2529
|
-
<style id="smx-structured-style">
|
|
2570
|
+
|
|
2530
2571
|
/* Container for structured bot content */
|
|
2531
2572
|
.chat-message.bot .smx-structured {{
|
|
2532
2573
|
margin-top: 4px;
|
|
@@ -2540,10 +2581,11 @@ def setup_routes(smx):
|
|
|
2540
2581
|
margin: 8px 0 4px;
|
|
2541
2582
|
font-weight: 700;
|
|
2542
2583
|
}}
|
|
2543
|
-
|
|
2544
|
-
.chat-message.bot .smx-structured
|
|
2545
|
-
.chat-message.bot .smx-structured
|
|
2546
|
-
|
|
2584
|
+
|
|
2585
|
+
.chat-message.bot .smx-structured h1 {{ font-size: 1.3rem; }}
|
|
2586
|
+
.chat-message.bot .smx-structured h2 {{ font-size: 1.2rem; }}
|
|
2587
|
+
.chat-message.bot .smx-structured h3 {{ font-size: 1.1rem; }}
|
|
2588
|
+
|
|
2547
2589
|
/* Paragraphs */
|
|
2548
2590
|
.chat-message.bot .smx-structured p {{
|
|
2549
2591
|
margin: 6px 0;
|
|
@@ -2563,15 +2605,13 @@ def setup_routes(smx):
|
|
|
2563
2605
|
padding: 8px 10px;
|
|
2564
2606
|
border-radius: 8px;
|
|
2565
2607
|
overflow: auto;
|
|
2566
|
-
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
|
2567
2608
|
}}
|
|
2568
2609
|
|
|
2569
|
-
/* While streaming
|
|
2610
|
+
/* While streaming, still use a live typing box */
|
|
2570
2611
|
.chat-message.bot.streaming .stream-target {{
|
|
2571
2612
|
white-space: pre-wrap; /* so newlines render during typing */
|
|
2572
2613
|
}}
|
|
2573
|
-
|
|
2574
|
-
<style>
|
|
2614
|
+
|
|
2575
2615
|
/* --- Stop-in-a-ring spinner --- */
|
|
2576
2616
|
#submit-button.stop {{
|
|
2577
2617
|
display: inline-flex;
|
|
@@ -2611,8 +2651,7 @@ def setup_routes(smx):
|
|
|
2611
2651
|
@keyframes smxSpin {{
|
|
2612
2652
|
to {{ transform: rotate(360deg); }}
|
|
2613
2653
|
}}
|
|
2614
|
-
|
|
2615
|
-
<style>
|
|
2654
|
+
|
|
2616
2655
|
/* Force strict top→bottom stacking and align sides without floats */
|
|
2617
2656
|
#chat-history{{
|
|
2618
2657
|
display: flex;
|
|
@@ -2661,6 +2700,14 @@ def setup_routes(smx):
|
|
|
2661
2700
|
body {{
|
|
2662
2701
|
padding-bottom:0;
|
|
2663
2702
|
}}
|
|
2703
|
+
|
|
2704
|
+
html, body {{
|
|
2705
|
+
margin: 0;
|
|
2706
|
+
padding: 0;
|
|
2707
|
+
width: 100%;
|
|
2708
|
+
height: 100%; /* If you want it to be full height too */
|
|
2709
|
+
overflow-x: hidden; /* Optional: Prevents horizontal scrollbar appearing if a slight overflow */
|
|
2710
|
+
}}
|
|
2664
2711
|
</style>
|
|
2665
2712
|
|
|
2666
2713
|
<body>
|
|
@@ -3080,7 +3127,7 @@ def setup_routes(smx):
|
|
|
3080
3127
|
}) + "\n\n"
|
|
3081
3128
|
|
|
3082
3129
|
except GeneratorExit:
|
|
3083
|
-
|
|
3130
|
+
return "Client aborted the stream."
|
|
3084
3131
|
except Exception as e:
|
|
3085
3132
|
smx.error(f"Stream error: {e}")
|
|
3086
3133
|
yield "data: " + json.dumps({"event": "error", "error": str(e)}) + "\n\n"
|
|
@@ -3111,7 +3158,7 @@ def setup_routes(smx):
|
|
|
3111
3158
|
yield "data: " + json.dumps({ "event": "cancelled" }) + "\n\n"
|
|
3112
3159
|
|
|
3113
3160
|
try:
|
|
3114
|
-
gen = smx.process_query_stream(**sa)
|
|
3161
|
+
gen = smx.process_query_stream(**sa)
|
|
3115
3162
|
except Exception as e:
|
|
3116
3163
|
smx.error(f"Could not start stream: {e}")
|
|
3117
3164
|
return jsonify({"error": "stream_start_failed", "message": str(e)})
|
|
@@ -3279,6 +3326,10 @@ def setup_routes(smx):
|
|
|
3279
3326
|
def upload_user_file():
|
|
3280
3327
|
import uuid
|
|
3281
3328
|
from flask import jsonify
|
|
3329
|
+
|
|
3330
|
+
if not getattr(smx, "user_files_enabled", False):
|
|
3331
|
+
return jsonify({"error": "user_files_disabled"}), 403
|
|
3332
|
+
|
|
3282
3333
|
# Define the upload folder for user files.
|
|
3283
3334
|
upload_folder = os.path.join(_CLIENT_DIR, "uploads", "user")
|
|
3284
3335
|
if not os.path.exists(upload_folder):
|
|
@@ -3383,28 +3434,43 @@ def setup_routes(smx):
|
|
|
3383
3434
|
}
|
|
3384
3435
|
.admin-sidenav a:hover,.admin-sidenav a.active{background:#DADADA}
|
|
3385
3436
|
|
|
3386
|
-
/*
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
padding-right: var(--right) !important; /* keep your right gutter */
|
|
3396
|
-
box-sizing: border-box;
|
|
3397
|
-
max-width: 100%;
|
|
3437
|
+
/* Admin overlay + toggle (desktop: hidden) */
|
|
3438
|
+
.admin-scrim{
|
|
3439
|
+
position: fixed;
|
|
3440
|
+
inset: 0;
|
|
3441
|
+
background: rgba(0,0,0,.25);
|
|
3442
|
+
z-index: 1000;
|
|
3443
|
+
opacity: 0;
|
|
3444
|
+
pointer-events: none;
|
|
3445
|
+
transition: opacity .2s ease;
|
|
3398
3446
|
}
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
}
|
|
3447
|
+
.admin-scrim.show{
|
|
3448
|
+
opacity: 1;
|
|
3449
|
+
pointer-events: auto;
|
|
3403
3450
|
}
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3451
|
+
|
|
3452
|
+
.admin-sidebar-toggle{
|
|
3453
|
+
display: none; /* only visible on mobile */
|
|
3454
|
+
}
|
|
3455
|
+
|
|
3456
|
+
/* shared with dashboard drawer logic */
|
|
3457
|
+
body.no-scroll{
|
|
3458
|
+
overflow: hidden;
|
|
3459
|
+
}
|
|
3460
|
+
|
|
3461
|
+
/* Main content with balanced margins (desktop ≥ 901px) */
|
|
3462
|
+
@media (min-width: 901px){
|
|
3463
|
+
.admin-main{
|
|
3464
|
+
margin-left: calc(var(--sidenav-w) + 3px); /* 1px for the border */
|
|
3465
|
+
margin-top: var(--nav-h);
|
|
3466
|
+
margin-bottom: 0;
|
|
3467
|
+
padding: 0 10px; /* keep your left gutter */
|
|
3468
|
+
margin-right: 0 !important; /* stop over-wide total */
|
|
3469
|
+
width: calc(100% - var(--sidenav-w)) !important; /* % not vw */
|
|
3470
|
+
padding-right: var(--right) !important; /* keep your right gutter */
|
|
3471
|
+
box-sizing: border-box;
|
|
3472
|
+
max-width: 100%;
|
|
3473
|
+
}
|
|
3408
3474
|
}
|
|
3409
3475
|
|
|
3410
3476
|
/* Section demarcation */
|
|
@@ -3462,7 +3528,7 @@ def setup_routes(smx):
|
|
|
3462
3528
|
.span-12 { grid-column: span 12; }
|
|
3463
3529
|
|
|
3464
3530
|
/* Lists */
|
|
3465
|
-
.catalog-list{max-height:
|
|
3531
|
+
.catalog-list{max-height:200px;overflow:auto;margin:0;padding:0;list-style:none}
|
|
3466
3532
|
.catalog-list li{
|
|
3467
3533
|
display:flex;align-items:center;justify-content:space-between;gap:4px;
|
|
3468
3534
|
padding:1px 2px;border-bottom:1px solid #eee;font-size:.7rem;
|
|
@@ -3506,25 +3572,66 @@ def setup_routes(smx):
|
|
|
3506
3572
|
}
|
|
3507
3573
|
}
|
|
3508
3574
|
|
|
3509
|
-
/* Mobile */
|
|
3575
|
+
/* Mobile: off-canvas drawer from the left (like dashboard) */
|
|
3510
3576
|
@media (max-width: 900px){
|
|
3511
|
-
.admin-sidenav{
|
|
3577
|
+
.admin-sidenav{
|
|
3578
|
+
position: fixed;
|
|
3579
|
+
top: var(--nav-h);
|
|
3580
|
+
left: 0;
|
|
3581
|
+
width: 24vw; /* narrower drawer */
|
|
3582
|
+
max-width: 96px; /* cap on larger phones */
|
|
3583
|
+
height: calc(100vh - var(--nav-h));
|
|
3584
|
+
transform: translateX(-100%);
|
|
3585
|
+
transition: transform .28s ease;
|
|
3586
|
+
z-index: 1100;
|
|
3587
|
+
border-radius: 0 10px 10px 0;
|
|
3588
|
+
}
|
|
3589
|
+
.admin-sidenav.open{
|
|
3590
|
+
transform: translateX(0);
|
|
3591
|
+
}
|
|
3592
|
+
|
|
3512
3593
|
.admin-main{
|
|
3513
|
-
margin-
|
|
3514
|
-
margin-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
padding: 0;
|
|
3594
|
+
margin-left: 0;
|
|
3595
|
+
margin-right: 0;
|
|
3596
|
+
width: 100%;
|
|
3597
|
+
padding: 8px 8px 16px;
|
|
3518
3598
|
box-sizing: border-box;
|
|
3519
|
-
max-width: 100%;
|
|
3599
|
+
max-width: 100%;
|
|
3600
|
+
}
|
|
3601
|
+
|
|
3602
|
+
/* Floating blue toggle button (hamburger / close) */
|
|
3603
|
+
.admin-sidebar-toggle{
|
|
3604
|
+
position: fixed;
|
|
3605
|
+
top: calc(var(--nav-h) + 8px); /* sit just below the blue header */
|
|
3606
|
+
left: 10px;
|
|
3607
|
+
z-index: 1200;
|
|
3608
|
+
display: inline-flex;
|
|
3609
|
+
align-items: center;
|
|
3610
|
+
justify-content: center;
|
|
3611
|
+
width: 40px;
|
|
3612
|
+
height: 40px;
|
|
3613
|
+
border: 0;
|
|
3614
|
+
border-radius: 10px;
|
|
3615
|
+
background: #0d6efd;
|
|
3616
|
+
color: #fff;
|
|
3617
|
+
box-shadow: 0 4px 14px rgba(0,0,0,.18);
|
|
3618
|
+
cursor: pointer;
|
|
3619
|
+
}
|
|
3620
|
+
.admin-sidebar-toggle::before{
|
|
3621
|
+
content: "☰";
|
|
3622
|
+
font-size: 22px;
|
|
3623
|
+
line-height: 1;
|
|
3624
|
+
}
|
|
3625
|
+
.admin-sidebar-toggle.is-open::before{
|
|
3626
|
+
content: "✕";
|
|
3520
3627
|
}
|
|
3521
|
-
|
|
3522
|
-
/* force all grid items to stack */
|
|
3523
|
-
.span-3, .span-4, .span-6, .span-8, .span-12 { grid-column: span 12; }
|
|
3524
|
-
}
|
|
3525
3628
|
|
|
3526
|
-
|
|
3527
|
-
|
|
3629
|
+
/* Stack cards one per row on narrow screens */
|
|
3630
|
+
.span-2, .span-3, .span-4, .span-5, .span-6, .span-7,
|
|
3631
|
+
.span-8, .span-9, .span-10, .span-12 {
|
|
3632
|
+
grid-column: span 12;
|
|
3633
|
+
}
|
|
3634
|
+
}
|
|
3528
3635
|
|
|
3529
3636
|
/* Prevent any inner block from insisting on a width that causes overflow */
|
|
3530
3637
|
.admin-shell .card, .admin-grid { min-width: 0; }
|
|
@@ -3601,12 +3708,13 @@ def setup_routes(smx):
|
|
|
3601
3708
|
.catalog-list li:hover {
|
|
3602
3709
|
background: #D3E3D3;
|
|
3603
3710
|
}
|
|
3604
|
-
|
|
3711
|
+
#users > div > div > ul > li > form > button {
|
|
3605
3712
|
font-size: 0.7rem;
|
|
3606
3713
|
margin: 0;
|
|
3607
3714
|
padding: 0 !important;
|
|
3608
3715
|
border: 0.5px dashed gray;
|
|
3609
3716
|
}
|
|
3717
|
+
|
|
3610
3718
|
/* Fix: stop inputs/selects inside cards spilling out (desktop & tablet) */
|
|
3611
3719
|
.admin-shell .card > * { min-width: 0; }
|
|
3612
3720
|
.admin-shell .card input,
|
|
@@ -3619,13 +3727,53 @@ def setup_routes(smx):
|
|
|
3619
3727
|
}
|
|
3620
3728
|
.admin-shell .card input:not([type="checkbox"]):not([type="radio"]),
|
|
3621
3729
|
.admin-shell .card select,
|
|
3622
|
-
.admin-shell .card textarea{
|
|
3730
|
+
.admin-shell .card textarea {
|
|
3623
3731
|
display:block;
|
|
3624
3732
|
width:100%;
|
|
3625
3733
|
max-width:100%;
|
|
3626
3734
|
box-sizing:border-box;
|
|
3627
3735
|
}
|
|
3628
3736
|
|
|
3737
|
+
/* ── Manage Pages overrides: compact single-row controls inside the list ── */
|
|
3738
|
+
#pages .catalog-list li {
|
|
3739
|
+
align-items: center;
|
|
3740
|
+
}
|
|
3741
|
+
|
|
3742
|
+
#pages .catalog-list li form {
|
|
3743
|
+
display: flex;
|
|
3744
|
+
align-items: center;
|
|
3745
|
+
justify-content: space-between;
|
|
3746
|
+
gap: 0.4rem;
|
|
3747
|
+
width: 100%;
|
|
3748
|
+
flex-wrap: nowrap;
|
|
3749
|
+
}
|
|
3750
|
+
|
|
3751
|
+
#pages .catalog-list li form input,
|
|
3752
|
+
#pages .catalog-list li form select,
|
|
3753
|
+
#pages .catalog-list li form button {
|
|
3754
|
+
display: inline-block;
|
|
3755
|
+
width: auto;
|
|
3756
|
+
max-width: 10rem;
|
|
3757
|
+
box-sizing: border-box;
|
|
3758
|
+
}
|
|
3759
|
+
|
|
3760
|
+
#pages .catalog-list li form input[type="text"] {
|
|
3761
|
+
flex: 1 1 160px; /* nav label / title can grow */
|
|
3762
|
+
}
|
|
3763
|
+
|
|
3764
|
+
#pages .catalog-list li form input[type="number"] {
|
|
3765
|
+
width: 3rem;
|
|
3766
|
+
flex: 0 0 auto; /* small fixed width for order */
|
|
3767
|
+
}
|
|
3768
|
+
|
|
3769
|
+
#pages .catalog-list li form label {
|
|
3770
|
+
display: inline-flex;
|
|
3771
|
+
align-items: center;
|
|
3772
|
+
gap: 0.3rem;
|
|
3773
|
+
white-space: nowrap;
|
|
3774
|
+
margin: 0;
|
|
3775
|
+
}
|
|
3776
|
+
|
|
3629
3777
|
/* Restore normal checkbox/radio sizing & alignment */
|
|
3630
3778
|
.admin-shell .card input[type="checkbox"],
|
|
3631
3779
|
.admin-shell .card input[type="radio"]{
|
|
@@ -3645,18 +3793,13 @@ def setup_routes(smx):
|
|
|
3645
3793
|
}
|
|
3646
3794
|
/* If fixed and its height is constant (e.g., 56px) */
|
|
3647
3795
|
body { padding-top: 46px; } /* make room for the bar */
|
|
3648
|
-
|
|
3649
|
-
.admin-sidenav { /* keep the sidebar aligned */
|
|
3650
|
-
top: 56px;
|
|
3651
|
-
height: calc(100vh - 56px);
|
|
3652
|
-
}
|
|
3796
|
+
|
|
3653
3797
|
#del-embed-btn, .del-btn {
|
|
3654
3798
|
padding: 0;
|
|
3655
3799
|
font-size: 0.6rem;
|
|
3656
3800
|
border: none;
|
|
3657
3801
|
text-decoration: none;
|
|
3658
3802
|
}
|
|
3659
|
-
|
|
3660
3803
|
</style>
|
|
3661
3804
|
"""
|
|
3662
3805
|
|
|
@@ -3708,15 +3851,199 @@ def setup_routes(smx):
|
|
|
3708
3851
|
f"Generated {total_chunks} chunk(s)."
|
|
3709
3852
|
)
|
|
3710
3853
|
|
|
3854
|
+
|
|
3711
3855
|
elif action == "add_page":
|
|
3712
|
-
|
|
3713
|
-
page_name = page_name.lower()
|
|
3714
|
-
|
|
3715
|
-
|
|
3856
|
+
# Core fields
|
|
3857
|
+
page_name = (request.form.get("page_name") or "").strip().lower()
|
|
3858
|
+
|
|
3859
|
+
def _slugify(s: str) -> str:
|
|
3860
|
+
s = (s or "").strip().lower()
|
|
3861
|
+
s = s.replace("_", "-")
|
|
3862
|
+
s = re.sub(r"\s+", "-", s)
|
|
3863
|
+
s = re.sub(r"[^a-z0-9\-]+", "", s)
|
|
3864
|
+
s = re.sub(r"-{2,}", "-", s).strip("-")
|
|
3865
|
+
return s or "page"
|
|
3866
|
+
requested_slug = _slugify(page_name)
|
|
3867
|
+
base_slug = requested_slug
|
|
3868
|
+
|
|
3869
|
+
# Find a free slug (auto-suffix)
|
|
3870
|
+
final_slug = base_slug
|
|
3871
|
+
n = 2
|
|
3872
|
+
while final_slug in (smx.pages or {}):
|
|
3873
|
+
final_slug = f"{base_slug}-{n}"
|
|
3874
|
+
n += 1
|
|
3875
|
+
page_name = final_slug
|
|
3876
|
+
|
|
3877
|
+
site_desc = (request.form.get("site_desc") or "").strip()
|
|
3878
|
+
|
|
3879
|
+
# Nav-related fields from the form
|
|
3880
|
+
show_in_nav_raw = request.form.get("show_in_nav")
|
|
3881
|
+
show_in_nav = bool(show_in_nav_raw)
|
|
3882
|
+
nav_label = (request.form.get("nav_label") or "").strip()
|
|
3883
|
+
|
|
3884
|
+
|
|
3885
|
+
# Compile to modern HTML with icons + animations
|
|
3886
|
+
# Use instance website description unless the form provides a new one
|
|
3887
|
+
if site_desc:
|
|
3716
3888
|
smx.set_website_description(site_desc)
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3889
|
+
|
|
3890
|
+
base_slug = (page_name or "").strip().lower()
|
|
3891
|
+
if not base_slug:
|
|
3892
|
+
flash("Page name is required.", "error")
|
|
3893
|
+
return redirect(url_for("admin_panel"))
|
|
3894
|
+
|
|
3895
|
+
# Auto-suffix if slug clashes
|
|
3896
|
+
final_slug = base_slug
|
|
3897
|
+
if final_slug in (smx.pages or {}):
|
|
3898
|
+
n = 2
|
|
3899
|
+
while f"{base_slug}-{n}" in (smx.pages or {}):
|
|
3900
|
+
n += 1
|
|
3901
|
+
final_slug = f"{base_slug}-{n}"
|
|
3902
|
+
|
|
3903
|
+
# Pull Pixabay key if you have it in DB (best-effort)
|
|
3904
|
+
pixabay_key = ""
|
|
3905
|
+
try:
|
|
3906
|
+
if hasattr(db, "get_secret"):
|
|
3907
|
+
pixabay_key = db.get_secret("PIXABAY_API_KEY") or ""
|
|
3908
|
+
except Exception:
|
|
3909
|
+
pixabay_key = ""
|
|
3910
|
+
|
|
3911
|
+
# Agentic generation (Gemini → plan → validate → Pixabay → compile)
|
|
3912
|
+
result = agentic_generate_page(
|
|
3913
|
+
page_slug=final_slug,
|
|
3914
|
+
website_description=smx.website_description,
|
|
3915
|
+
client_dir=_CLIENT_DIR,
|
|
3916
|
+
pixabay_api_key=pixabay_key,
|
|
3917
|
+
llm_profile=smx.current_profile("coder"),
|
|
3918
|
+
)
|
|
3919
|
+
|
|
3920
|
+
page_content_html = result["html"]
|
|
3921
|
+
layout_plan = result["plan"]
|
|
3922
|
+
|
|
3923
|
+
# Persist page content
|
|
3924
|
+
if final_slug not in smx.pages:
|
|
3925
|
+
db.add_page(final_slug, page_content_html)
|
|
3926
|
+
smx.pages = db.get_pages()
|
|
3927
|
+
else:
|
|
3928
|
+
db.update_page(final_slug, final_slug, page_content_html)
|
|
3929
|
+
smx.pages = db.get_pages()
|
|
3930
|
+
|
|
3931
|
+
# If you have page_layouts support, store the plan for the builder
|
|
3932
|
+
try:
|
|
3933
|
+
if hasattr(db, "upsert_page_layout"):
|
|
3934
|
+
db.upsert_page_layout(final_slug, json.dumps(layout_plan), is_detached=False)
|
|
3935
|
+
except Exception as e:
|
|
3936
|
+
smx.warning(f"upsert_page_layout failed for '{final_slug}': {e}")
|
|
3937
|
+
|
|
3938
|
+
# Nav label default
|
|
3939
|
+
if not nav_label:
|
|
3940
|
+
nav_label = final_slug.capitalize()
|
|
3941
|
+
|
|
3942
|
+
# Compute default nav order
|
|
3943
|
+
nav_order = None
|
|
3944
|
+
try:
|
|
3945
|
+
nav_meta_all = db.get_page_nav_map()
|
|
3946
|
+
existing_orders = [
|
|
3947
|
+
meta.get("nav_order")
|
|
3948
|
+
for meta in nav_meta_all.values()
|
|
3949
|
+
if meta.get("nav_order") is not None
|
|
3950
|
+
]
|
|
3951
|
+
nav_order = (max(existing_orders) + 1) if existing_orders else 1
|
|
3952
|
+
except Exception as e:
|
|
3953
|
+
smx.warning(f"Could not compute nav order for '{final_slug}': {e}")
|
|
3954
|
+
nav_order = None
|
|
3955
|
+
|
|
3956
|
+
try:
|
|
3957
|
+
db.set_page_nav(
|
|
3958
|
+
final_slug,
|
|
3959
|
+
show_in_nav=show_in_nav,
|
|
3960
|
+
nav_label=nav_label,
|
|
3961
|
+
nav_order=nav_order,
|
|
3962
|
+
)
|
|
3963
|
+
except Exception as e:
|
|
3964
|
+
smx.warning(f"set_page_nav failed for '{final_slug}': {e}")
|
|
3965
|
+
|
|
3966
|
+
# Show banner only on builder/edit page after generation
|
|
3967
|
+
session["published_as"] = final_slug
|
|
3968
|
+
return redirect(url_for("edit_page", page_name=final_slug, published_as=final_slug))
|
|
3969
|
+
|
|
3970
|
+
elif action == "update_page_nav":
|
|
3971
|
+
# Update nav visibility / label / order for an existing page
|
|
3972
|
+
page_name = (request.form.get("page_name") or "").strip().lower()
|
|
3973
|
+
show_raw = request.form.get("show_in_nav")
|
|
3974
|
+
show_in_nav = bool(show_raw)
|
|
3975
|
+
nav_label = (request.form.get("nav_label") or "").strip()
|
|
3976
|
+
nav_order_raw = (request.form.get("nav_order") or "").strip()
|
|
3977
|
+
|
|
3978
|
+
nav_order = None
|
|
3979
|
+
if nav_order_raw:
|
|
3980
|
+
try:
|
|
3981
|
+
nav_order = int(nav_order_raw)
|
|
3982
|
+
except ValueError:
|
|
3983
|
+
nav_order = None
|
|
3984
|
+
|
|
3985
|
+
if page_name:
|
|
3986
|
+
if not nav_label:
|
|
3987
|
+
nav_label = page_name.capitalize()
|
|
3988
|
+
try:
|
|
3989
|
+
db.set_page_nav(
|
|
3990
|
+
page_name,
|
|
3991
|
+
show_in_nav=show_in_nav,
|
|
3992
|
+
nav_label=nav_label,
|
|
3993
|
+
nav_order=nav_order,
|
|
3994
|
+
)
|
|
3995
|
+
except Exception as e:
|
|
3996
|
+
smx.warning(f"update_page_nav failed for '{page_name}': {e}")
|
|
3997
|
+
|
|
3998
|
+
return redirect(url_for("admin_panel"))
|
|
3999
|
+
|
|
4000
|
+
elif action == "reorder_pages":
|
|
4001
|
+
"""
|
|
4002
|
+
Persist a new navigation order for pages.
|
|
4003
|
+
Expects a comma-separated list of page names in `page_order_csv`.
|
|
4004
|
+
"""
|
|
4005
|
+
order_csv = (request.form.get("page_order_csv") or "").strip()
|
|
4006
|
+
if order_csv:
|
|
4007
|
+
# normalise and dedupe while preserving order
|
|
4008
|
+
raw_names = [n.strip() for n in order_csv.split(",") if n.strip()]
|
|
4009
|
+
seen = set()
|
|
4010
|
+
ordered_names = []
|
|
4011
|
+
for nm in raw_names:
|
|
4012
|
+
if nm in seen:
|
|
4013
|
+
continue
|
|
4014
|
+
seen.add(nm)
|
|
4015
|
+
ordered_names.append(nm)
|
|
4016
|
+
|
|
4017
|
+
try:
|
|
4018
|
+
nav_meta = db.get_page_nav_map()
|
|
4019
|
+
except Exception as e:
|
|
4020
|
+
smx.warning(f"admin_panel: get_page_nav_map failed while reordering pages: {e}")
|
|
4021
|
+
nav_meta = {}
|
|
4022
|
+
|
|
4023
|
+
order_idx = 1
|
|
4024
|
+
for name in ordered_names:
|
|
4025
|
+
# Try to find any existing meta for this page
|
|
4026
|
+
meta = (
|
|
4027
|
+
nav_meta.get(name)
|
|
4028
|
+
or nav_meta.get(name.lower())
|
|
4029
|
+
or {}
|
|
4030
|
+
)
|
|
4031
|
+
show_in_nav = meta.get("show_in_nav", True)
|
|
4032
|
+
nav_label = meta.get("nav_label") or name.capitalize()
|
|
4033
|
+
|
|
4034
|
+
try:
|
|
4035
|
+
db.set_page_nav(
|
|
4036
|
+
name,
|
|
4037
|
+
show_in_nav=show_in_nav,
|
|
4038
|
+
nav_label=nav_label,
|
|
4039
|
+
nav_order=order_idx,
|
|
4040
|
+
)
|
|
4041
|
+
except Exception as e:
|
|
4042
|
+
smx.warning(f"admin_panel: set_page_nav failed for {name}: {e}")
|
|
4043
|
+
order_idx += 1
|
|
4044
|
+
|
|
4045
|
+
# Always bounce back to the admin panel (avoid re-POST)
|
|
4046
|
+
return redirect(url_for("admin_panel"))
|
|
3720
4047
|
|
|
3721
4048
|
|
|
3722
4049
|
elif action == "save_llm":
|
|
@@ -3742,7 +4069,7 @@ def setup_routes(smx):
|
|
|
3742
4069
|
prov = request.form["provider"]
|
|
3743
4070
|
model = request.form["model"]
|
|
3744
4071
|
tag = request.form["purpose"]
|
|
3745
|
-
desc = request.form["desc"]
|
|
4072
|
+
desc = request.form["desc"]
|
|
3746
4073
|
|
|
3747
4074
|
if not any(r for r in catalog if r["provider"] == prov and r["model"] == model):
|
|
3748
4075
|
flash("Provider/model not in catalog", "error")
|
|
@@ -3753,7 +4080,7 @@ def setup_routes(smx):
|
|
|
3753
4080
|
provider = request.form.get("provider", "").strip(),
|
|
3754
4081
|
model = request.form.get("model", "").strip(),
|
|
3755
4082
|
api_key = request.form.get("api_key", "").strip(),
|
|
3756
|
-
purpose = request.form.get("purpose", "").strip()
|
|
4083
|
+
purpose = request.form.get("purpose", "").strip(),
|
|
3757
4084
|
desc = request.form.get("desc", "").strip(),
|
|
3758
4085
|
)
|
|
3759
4086
|
_prof.refresh_profiles_cache()
|
|
@@ -3820,6 +4147,34 @@ def setup_routes(smx):
|
|
|
3820
4147
|
)
|
|
3821
4148
|
flash(f"Role '{name}' created.", "info") if ok else flash("Could not create role (reserved/exists/invalid).", "error")
|
|
3822
4149
|
|
|
4150
|
+
elif action == "create_user":
|
|
4151
|
+
viewer_role = (session.get("role") or "").lower()
|
|
4152
|
+
if viewer_role not in ("admin", "superadmin"):
|
|
4153
|
+
flash("You are not authorised to create user.", "error")
|
|
4154
|
+
else:
|
|
4155
|
+
email = (request.form.get("email") or "").strip()
|
|
4156
|
+
username = (request.form.get("username") or "").strip()
|
|
4157
|
+
temp_password = request.form.get("password") or ""
|
|
4158
|
+
role = (request.form.get("role") or "user").strip().lower()
|
|
4159
|
+
|
|
4160
|
+
if not email or not temp_password:
|
|
4161
|
+
flash("Email and password are required to create a user.", "error")
|
|
4162
|
+
elif role not in ("user", "employee"):
|
|
4163
|
+
flash("Invalid role for new user.", "error")
|
|
4164
|
+
else:
|
|
4165
|
+
ok = register_user(email, username, temp_password, role)
|
|
4166
|
+
if ok:
|
|
4167
|
+
# Force this new account to change password on first login
|
|
4168
|
+
_auth.set_must_reset_by_email(email, must_reset=True)
|
|
4169
|
+
flash(
|
|
4170
|
+
"User created. They must change the temporary password on first login.",
|
|
4171
|
+
"success",
|
|
4172
|
+
)
|
|
4173
|
+
else:
|
|
4174
|
+
flash("Could not create user (email or username already in use).", "error")
|
|
4175
|
+
|
|
4176
|
+
return redirect(url_for("admin_panel"))
|
|
4177
|
+
|
|
3823
4178
|
elif action == "set_user_role":
|
|
3824
4179
|
actor_role = (session.get("role") or "").lower()
|
|
3825
4180
|
actor_id = session.get("user_id")
|
|
@@ -3846,7 +4201,7 @@ def setup_routes(smx):
|
|
|
3846
4201
|
|
|
3847
4202
|
elif action == "confirm_delete_user":
|
|
3848
4203
|
if (session.get("role") or "").lower() != "superadmin":
|
|
3849
|
-
flash("
|
|
4204
|
+
flash("You are not authorised to delete accounts.", "error")
|
|
3850
4205
|
else:
|
|
3851
4206
|
session["pending_delete_user_id"] = int(request.form.get("user_id") or 0)
|
|
3852
4207
|
flash("Confirm deletion below.", "warning")
|
|
@@ -3856,7 +4211,7 @@ def setup_routes(smx):
|
|
|
3856
4211
|
|
|
3857
4212
|
elif action == "delete_user":
|
|
3858
4213
|
if (session.get("role") or "").lower() != "superadmin":
|
|
3859
|
-
flash("You
|
|
4214
|
+
flash("You are not authorised to delete account.", "error")
|
|
3860
4215
|
else:
|
|
3861
4216
|
target_id = session.get("pending_delete_user_id")
|
|
3862
4217
|
if target_id:
|
|
@@ -3888,7 +4243,7 @@ def setup_routes(smx):
|
|
|
3888
4243
|
# ────────────────────────────────────────────────────────────────────────────────
|
|
3889
4244
|
embedding_model = _llms.load_embed_model()
|
|
3890
4245
|
embeddings_setup_card = f"""
|
|
3891
|
-
<div class="card span-
|
|
4246
|
+
<div class="card span-3">
|
|
3892
4247
|
<h4>Setup Embedding Model</h4>
|
|
3893
4248
|
<form method="post" style="display:inline-block; margin-right:8px;">
|
|
3894
4249
|
<input type="hidden" name="action" value="save_llm">
|
|
@@ -3949,7 +4304,7 @@ def setup_routes(smx):
|
|
|
3949
4304
|
# LLMs
|
|
3950
4305
|
# ────────────────────────────────────────────────────────────────────────────────
|
|
3951
4306
|
Add_model_catalog_card = f"""
|
|
3952
|
-
<div class="card span-
|
|
4307
|
+
<div class="card span-3">
|
|
3953
4308
|
<h3>Add Model To Catalogue</h3>
|
|
3954
4309
|
<form method="post" style="margin-bottom:0.5rem;">
|
|
3955
4310
|
<label for="catalog_prov">Provider</label>
|
|
@@ -4080,7 +4435,7 @@ def setup_routes(smx):
|
|
|
4080
4435
|
"""
|
|
4081
4436
|
|
|
4082
4437
|
models_catalog_list_card = f"""
|
|
4083
|
-
<div class="card span-
|
|
4438
|
+
<div class="card span-6">
|
|
4084
4439
|
<h4>Models Catalogue</h4>
|
|
4085
4440
|
<ul class="catalog-list">
|
|
4086
4441
|
{cat_items or "<li class='li-row'>No models yet.</li>"}
|
|
@@ -4118,7 +4473,7 @@ def setup_routes(smx):
|
|
|
4118
4473
|
|
|
4119
4474
|
<input type='hidden' id='purpose-field' name='purpose'>
|
|
4120
4475
|
<input type='hidden' id='desc-field' name='desc'>
|
|
4121
|
-
|
|
4476
|
+
<br>
|
|
4122
4477
|
<button class='btn btn-primary' type='submit' name='action' value='add_profile'>Add / Update</button>
|
|
4123
4478
|
</form>
|
|
4124
4479
|
</div>
|
|
@@ -4150,15 +4505,21 @@ def setup_routes(smx):
|
|
|
4150
4505
|
<ul class="catalog-list" style="padding-left:1rem; margin-bottom:0;">
|
|
4151
4506
|
{profile_items or "<li class='li-row'>No profiles yet.</li>"}
|
|
4152
4507
|
</ul>
|
|
4508
|
+
|
|
4509
|
+
<!-- Refresh button (reload admin page; anchor back to Models section) -->
|
|
4510
|
+
<div style="display:flex; justify-content:flex-end; margin-top:10px;">
|
|
4511
|
+
<a class="btn" href="/admin?refresh=profiles#models" title="Reload to refresh profiles list">Refresh</a>
|
|
4512
|
+
</div>
|
|
4153
4513
|
</div>
|
|
4154
4514
|
"""
|
|
4155
4515
|
|
|
4516
|
+
|
|
4156
4517
|
# ────────────────────────────────────────────────────────────────────────────────
|
|
4157
4518
|
# SYSTEM FILES
|
|
4158
4519
|
# ────────────────────────────────────────────────────────────────────────────────
|
|
4159
4520
|
sys_files_card = f"""
|
|
4160
|
-
<div class="card span-
|
|
4161
|
-
<h4>Upload System Files
|
|
4521
|
+
<div class="card span-3">
|
|
4522
|
+
<h4>Upload System Files<br>(PDFs only)</h4>
|
|
4162
4523
|
<form id="form-upload" method="post" enctype="multipart/form-data" style="display:inline-block;">
|
|
4163
4524
|
<input type="file" name="upload_files" accept=".pdf" multiple>
|
|
4164
4525
|
<button type="submit" name="action" value="upload_files">Upload</button>
|
|
@@ -4189,7 +4550,7 @@ def setup_routes(smx):
|
|
|
4189
4550
|
"""
|
|
4190
4551
|
|
|
4191
4552
|
manage_sys_files_card = f"""
|
|
4192
|
-
<div class='card span-
|
|
4553
|
+
<div class='card span-3'>
|
|
4193
4554
|
<h4>Manage Company Files</h4>
|
|
4194
4555
|
<ul class="catalog-list" style="list-style:none; padding-left:0; margin:0;">
|
|
4195
4556
|
{sys_files_html or "<li>No company file has been uploaded yet.</li>"}
|
|
@@ -4204,48 +4565,105 @@ def setup_routes(smx):
|
|
|
4204
4565
|
upload_msg = session.pop("upload_msg", "")
|
|
4205
4566
|
alert_script = f"<script>alert('{upload_msg}');</script>" if upload_msg else ""
|
|
4206
4567
|
|
|
4568
|
+
# Load nav metadata (show_in_nav / nav_label) for existing pages
|
|
4569
|
+
try:
|
|
4570
|
+
nav_meta = db.get_page_nav_map()
|
|
4571
|
+
except Exception as e:
|
|
4572
|
+
smx.warning(f"get_page_nav_map failed in admin_panel: {e}")
|
|
4573
|
+
nav_meta = {}
|
|
4574
|
+
|
|
4207
4575
|
pages_html = ""
|
|
4208
4576
|
for p in smx.pages:
|
|
4577
|
+
meta = nav_meta.get(p.lower(), {})
|
|
4578
|
+
show_flag = meta.get("show_in_nav", True)
|
|
4579
|
+
label = meta.get("nav_label") or p.capitalize()
|
|
4580
|
+
nav_order_val = meta.get("nav_order")
|
|
4581
|
+
safe_label = html.escape(label, quote=True)
|
|
4582
|
+
order_display = "" if nav_order_val is None else html.escape(str(nav_order_val), quote=True)
|
|
4583
|
+
checked = "checked" if show_flag else ""
|
|
4584
|
+
|
|
4209
4585
|
pages_html += f"""
|
|
4210
4586
|
<li class="li-row" data-row-id="{p}">
|
|
4211
|
-
<
|
|
4212
|
-
|
|
4213
|
-
<
|
|
4214
|
-
<
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4587
|
+
<form method="post" style="display:flex; align-items:center; gap:0.4rem; justify-content:space-between; width:100%;">
|
|
4588
|
+
<input type="hidden" name="action" value="update_page_nav">
|
|
4589
|
+
<input type="hidden" name="page_name" value="{p}">
|
|
4590
|
+
<span style="flex:0 0 auto;">{p}</span>
|
|
4591
|
+
<span style="flex:1 1 auto; text-align:right; font-size:0.75rem;">
|
|
4592
|
+
<label style="display:inline-flex; align-items:center; gap:0.25rem; margin-right:0.4rem;">
|
|
4593
|
+
<input type="checkbox" name="show_in_nav" value="1" {checked} style="margin:0; width:auto;">
|
|
4594
|
+
<span>Show</span>
|
|
4595
|
+
</label>
|
|
4596
|
+
<input
|
|
4597
|
+
type="number"
|
|
4598
|
+
name="nav_order"
|
|
4599
|
+
value="{order_display}"
|
|
4600
|
+
placeholder="#"
|
|
4601
|
+
min="0"
|
|
4602
|
+
style="width:3rem; font-size:0.75rem; padding:2px 4px; border-radius:4px; border:1px solid #ccc; text-align:right; margin-right:0.25rem;"
|
|
4603
|
+
>
|
|
4604
|
+
<input
|
|
4605
|
+
type="text"
|
|
4606
|
+
name="nav_label"
|
|
4607
|
+
value="{safe_label}"
|
|
4608
|
+
placeholder="Nav label"
|
|
4609
|
+
style="max-width:8.5rem; font-size:0.75rem; padding:2px 4px; border-radius:4px; border:1px solid #ccc;"
|
|
4610
|
+
>
|
|
4611
|
+
<button type="submit" style="font-size:0.7rem; padding:2px 6px; margin-left:0.25rem;">
|
|
4612
|
+
Save
|
|
4613
|
+
</button>
|
|
4614
|
+
</span>
|
|
4615
|
+
<span style="flex:0 0 auto; margin-left:0.4rem;">
|
|
4616
|
+
<a class="edit-btn" href="/admin/edit/{p}" title="Edit {p}">🖊️</a>
|
|
4617
|
+
<a href="#"
|
|
4618
|
+
class="del-btn" title="Delete {p}"
|
|
4619
|
+
data-action="open-delete-modal"
|
|
4620
|
+
data-delete-url="/admin/delete.json"
|
|
4621
|
+
data-delete-field="page_name"
|
|
4622
|
+
data-delete-id="{p}"
|
|
4623
|
+
data-delete-label="page {p}"
|
|
4624
|
+
data-delete-extra='{{"resource":"page"}}'
|
|
4625
|
+
data-delete-remove="[data-row-id='{p}']">🗑️</a>
|
|
4626
|
+
</span>
|
|
4627
|
+
</form>
|
|
4224
4628
|
</li>
|
|
4225
4629
|
"""
|
|
4226
4630
|
|
|
4227
4631
|
add_new_page_card = f"""
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
<
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4632
|
+
<div class="card span-12">
|
|
4633
|
+
<h4>Generate New Page</h4>
|
|
4634
|
+
<form id="add-page-form" method="post">
|
|
4635
|
+
<input type="hidden" name="action" value="add_page">
|
|
4636
|
+
<input type="text" name="page_name" placeholder="Page Name" required>
|
|
4637
|
+
<textarea name="site_desc" placeholder="Website description"></textarea>
|
|
4638
|
+
<div style="display:flex; align-items:center; justify-content:space-between; margin-top:0.35rem;">
|
|
4639
|
+
<label style="display:inline-flex; align-items:center; gap:0.4rem; font-size:0.8rem;">
|
|
4640
|
+
<input type="checkbox" name="show_in_nav" checked style="margin:0; width:auto;">
|
|
4641
|
+
<span>Show in nav</span>
|
|
4642
|
+
</label>
|
|
4643
|
+
<input
|
|
4644
|
+
type="text"
|
|
4645
|
+
name="nav_label"
|
|
4646
|
+
placeholder="Navigation label (optional)"
|
|
4647
|
+
style="font-size:0.8rem; padding:3px 6px; max-width:11rem;"
|
|
4648
|
+
>
|
|
4649
|
+
</div>
|
|
4650
|
+
<div style="text-align:right; margin-top:0.4rem;">
|
|
4651
|
+
<button id="add-page-btn" type="submit">Generate</button>
|
|
4652
|
+
</div>
|
|
4653
|
+
</form>
|
|
4654
|
+
</div>
|
|
4655
|
+
"""
|
|
4239
4656
|
|
|
4240
4657
|
manage_page_card = f"""
|
|
4241
|
-
<div class="card span-
|
|
4658
|
+
<div class="card span-12">
|
|
4242
4659
|
<h4>Manage Pages</h4>
|
|
4243
|
-
<ul class="catalog-list">
|
|
4660
|
+
<ul id="pages-list" class="catalog-list">
|
|
4244
4661
|
{pages_html or "<li>No page has been added yet.</li>"}
|
|
4245
4662
|
</ul>
|
|
4246
4663
|
</div>
|
|
4247
4664
|
"""
|
|
4248
4665
|
|
|
4666
|
+
|
|
4249
4667
|
# ────────────────────────────────────────────────────────────────────────────────
|
|
4250
4668
|
# USERS & ROLES
|
|
4251
4669
|
# ────────────────────────────────────────────────────────────────────────────────
|
|
@@ -4382,14 +4800,45 @@ def setup_routes(smx):
|
|
|
4382
4800
|
"""
|
|
4383
4801
|
|
|
4384
4802
|
employees_card = f"""
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
|
|
4803
|
+
<div class="card span-12">
|
|
4804
|
+
<h4>Employees</h4>
|
|
4805
|
+
<ul class="catalog-list">
|
|
4806
|
+
{''.join(emp_items) or "<li>No employees yet.</li>"}
|
|
4807
|
+
</ul>
|
|
4808
|
+
{add_form}
|
|
4809
|
+
</div>
|
|
4392
4810
|
"""
|
|
4811
|
+
# Admin-only: create users directly (useful when public registration is disabled)
|
|
4812
|
+
create_user_card = ""
|
|
4813
|
+
if viewer_role in ("admin", "superadmin"):
|
|
4814
|
+
create_user_card = """
|
|
4815
|
+
<div class="card span-4">
|
|
4816
|
+
<h4>Create User</h4>
|
|
4817
|
+
<form method="post" class="form-vertical">
|
|
4818
|
+
<input type="hidden" name="action" value="create_user">
|
|
4819
|
+
<label>Email</label>
|
|
4820
|
+
<input type="email" name="email" required>
|
|
4821
|
+
|
|
4822
|
+
<label>Username (optional)</label>
|
|
4823
|
+
<input type="text" name="username" placeholder="e.g. jsmith">
|
|
4824
|
+
|
|
4825
|
+
<label>Temporary password</label>
|
|
4826
|
+
<input type="password" name="password" required>
|
|
4827
|
+
|
|
4828
|
+
<label>Role</label>
|
|
4829
|
+
<select name="role">
|
|
4830
|
+
<option value="user">User</option>
|
|
4831
|
+
<option value="employee">Employee</option>
|
|
4832
|
+
</select>
|
|
4833
|
+
|
|
4834
|
+
<button type="submit" style="margin-top:.5rem;">Create User</button>
|
|
4835
|
+
</form>
|
|
4836
|
+
<p style="font-size:.75rem;opacity:.7;margin-top:.5rem;">
|
|
4837
|
+
Share the temporary password securely and ask the user to change it after first login.
|
|
4838
|
+
</p>
|
|
4839
|
+
</div>
|
|
4840
|
+
"""
|
|
4841
|
+
|
|
4393
4842
|
from datetime import datetime, timedelta
|
|
4394
4843
|
# Audit (always its own row)
|
|
4395
4844
|
audit_card = ""
|
|
@@ -4512,22 +4961,68 @@ def setup_routes(smx):
|
|
|
4512
4961
|
</section>
|
|
4513
4962
|
"""
|
|
4514
4963
|
|
|
4964
|
+
existing_secret_names = []
|
|
4965
|
+
try:
|
|
4966
|
+
existing_secret_names = db.list_secret_names()
|
|
4967
|
+
except Exception:
|
|
4968
|
+
existing_secret_names = []
|
|
4969
|
+
|
|
4970
|
+
pixabay_saved = False
|
|
4971
|
+
try:
|
|
4972
|
+
pixabay_saved = bool(db.get_secret("PIXABAY_API_KEY") or os.environ.get("PIXABAY_API_KEY"))
|
|
4973
|
+
except Exception:
|
|
4974
|
+
pixabay_saved = bool(os.environ.get("PIXABAY_API_KEY"))
|
|
4975
|
+
|
|
4976
|
+
secretes_link_card = f"""
|
|
4977
|
+
<div class="card span-3">
|
|
4978
|
+
<h4>Integrations (Secrets)</h4>
|
|
4979
|
+
<div style="font-size:.72rem;color:#555;margin-top:-6px;margin-bottom:10px;line-height:1.35;">
|
|
4980
|
+
Store secrete credentials.
|
|
4981
|
+
</div>
|
|
4982
|
+
<a href="{url_for('admin_secretes')}" class="btn">Manage secretes</a>
|
|
4983
|
+
</div>
|
|
4984
|
+
"""
|
|
4985
|
+
|
|
4986
|
+
features_link_card = f"""
|
|
4987
|
+
<div class="card span-4">
|
|
4988
|
+
<h4>Feature toggles</h4>
|
|
4989
|
+
<div style="font-size:.72rem;color:#555;margin-top:-6px;margin-bottom:10px;line-height:1.35;">
|
|
4990
|
+
Turn streaming on/off and allow user file uploads in chat.
|
|
4991
|
+
</div>
|
|
4992
|
+
<a href="{url_for('admin_features')}" class="btn">Manage features</a>
|
|
4993
|
+
</div>
|
|
4994
|
+
"""
|
|
4995
|
+
|
|
4996
|
+
branding_link_card = f"""
|
|
4997
|
+
<div class="card span-3">
|
|
4998
|
+
<h4>Branding</h4>
|
|
4999
|
+
<div style="font-size:.72rem;color:#555;margin-top:-6px;margin-bottom:10px;line-height:1.35;">
|
|
5000
|
+
Upload your company logo and favicon (PNG/JPG). Defaults are used if nothing is uploaded.
|
|
5001
|
+
</div>
|
|
5002
|
+
<a href="{url_for('admin_branding')}" class="btn">Manage branding</a>
|
|
5003
|
+
</div>
|
|
5004
|
+
"""
|
|
5005
|
+
|
|
4515
5006
|
system_section = f"""
|
|
4516
5007
|
<section id="system" class="section">
|
|
4517
5008
|
<h2>System</h2>
|
|
4518
5009
|
<div class="admin-grid">
|
|
5010
|
+
{secretes_link_card}
|
|
5011
|
+
{branding_link_card}
|
|
5012
|
+
{features_link_card}
|
|
4519
5013
|
{sys_files_card}
|
|
4520
5014
|
{manage_sys_files_card}
|
|
4521
5015
|
</div>
|
|
5016
|
+
|
|
4522
5017
|
</section>
|
|
4523
5018
|
"""
|
|
4524
|
-
|
|
4525
5019
|
users_section = f"""
|
|
4526
5020
|
<section id="users" class="section">
|
|
4527
5021
|
<h2>Users</h2>
|
|
4528
5022
|
<div class="admin-grid">
|
|
4529
5023
|
{roles_card}
|
|
4530
5024
|
{employees_card}
|
|
5025
|
+
{create_user_card}
|
|
4531
5026
|
</div>
|
|
4532
5027
|
</section>
|
|
4533
5028
|
"""
|
|
@@ -4543,15 +5038,47 @@ def setup_routes(smx):
|
|
|
4543
5038
|
|
|
4544
5039
|
admin_shell = f"""{admin_layout_css}
|
|
4545
5040
|
<div class="admin-shell">
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
4549
|
-
|
|
4550
|
-
|
|
4551
|
-
|
|
4552
|
-
|
|
5041
|
+
<div id="adminSidebarScrim" class="admin-scrim" aria-hidden="true"></div>
|
|
5042
|
+
{side_nav}
|
|
5043
|
+
<div class="admin-main">
|
|
5044
|
+
<button id="adminSidebarToggle"
|
|
5045
|
+
class="admin-sidebar-toggle"
|
|
5046
|
+
aria-label="Open admin menu"></button>
|
|
5047
|
+
{models_section}
|
|
5048
|
+
{pages_section}
|
|
5049
|
+
{system_section}
|
|
5050
|
+
{users_section}
|
|
5051
|
+
{audits_section}
|
|
5052
|
+
</div>
|
|
4553
5053
|
</div>
|
|
4554
|
-
|
|
5054
|
+
<script>
|
|
5055
|
+
document.addEventListener('DOMContentLoaded', function () {{
|
|
5056
|
+
const sidebar = document.querySelector('.admin-sidenav');
|
|
5057
|
+
const toggle = document.getElementById('adminSidebarToggle');
|
|
5058
|
+
const scrim = document.getElementById('adminSidebarScrim');
|
|
5059
|
+
|
|
5060
|
+
function setOpen(open) {{
|
|
5061
|
+
if (!sidebar || !toggle) return;
|
|
5062
|
+
sidebar.classList.toggle('open', open);
|
|
5063
|
+
toggle.classList.toggle('is-open', open);
|
|
5064
|
+
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
5065
|
+
document.body.classList.toggle('no-scroll', open);
|
|
5066
|
+
if (scrim) scrim.classList.toggle('show', open);
|
|
5067
|
+
}}
|
|
5068
|
+
|
|
5069
|
+
if (toggle) {{
|
|
5070
|
+
toggle.addEventListener('click', function () {{
|
|
5071
|
+
setOpen(!sidebar.classList.contains('open'));
|
|
5072
|
+
}});
|
|
5073
|
+
}}
|
|
5074
|
+
|
|
5075
|
+
if (scrim) {{
|
|
5076
|
+
scrim.addEventListener('click', function () {{
|
|
5077
|
+
setOpen(false);
|
|
5078
|
+
}});
|
|
5079
|
+
}}
|
|
5080
|
+
}});
|
|
5081
|
+
</script>
|
|
4555
5082
|
"""
|
|
4556
5083
|
|
|
4557
5084
|
# ─────────────────────────────────────────────────────────
|
|
@@ -4587,16 +5114,19 @@ def setup_routes(smx):
|
|
|
4587
5114
|
{delete_modal_block}
|
|
4588
5115
|
|
|
4589
5116
|
<!-- Profiles helper scripts -->
|
|
4590
|
-
<script>
|
|
4591
|
-
/* Name suggestions popover
|
|
4592
|
-
const
|
|
4593
|
-
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
|
|
5117
|
+
<script>
|
|
5118
|
+
/* Name suggestions popover */
|
|
5119
|
+
const purpose_tags = {PURPOSE_TAGS}
|
|
5120
|
+
const nameExamples = {{}};
|
|
5121
|
+
const capitalize = (s) =>
|
|
5122
|
+
s.charAt(0).toUpperCase() + s.slice(1);
|
|
5123
|
+
for (let i = 0; i < purpose_tags.length; i++) {{
|
|
5124
|
+
purpose_tags[i] = capitalize(purpose_tags[i]);
|
|
5125
|
+
const tag = purpose_tags[i]
|
|
5126
|
+
const key = tag;
|
|
5127
|
+
nameExamples[key] = tag;
|
|
5128
|
+
}}
|
|
5129
|
+
|
|
4600
5130
|
const txt = document.getElementById('profile_name');
|
|
4601
5131
|
const infoBtn = document.getElementById('name-help');
|
|
4602
5132
|
const popover = document.getElementById('name-suggestions');
|
|
@@ -4722,7 +5252,7 @@ def setup_routes(smx):
|
|
|
4722
5252
|
|
|
4723
5253
|
if (form) {{
|
|
4724
5254
|
form.addEventListener('submit', function () {{
|
|
4725
|
-
if (btn) {{ btn.disabled = true; btn.textContent = '
|
|
5255
|
+
if (btn) {{ btn.disabled = true; btn.textContent = 'Generating…'; }}
|
|
4726
5256
|
if (overlay) overlay.style.display = 'flex';
|
|
4727
5257
|
}});
|
|
4728
5258
|
}}
|
|
@@ -4732,7 +5262,7 @@ def setup_routes(smx):
|
|
|
4732
5262
|
const o = document.getElementById('loader-overlay');
|
|
4733
5263
|
if (o) o.style.display = 'none';
|
|
4734
5264
|
const b = document.getElementById('add-page-btn');
|
|
4735
|
-
if (b) {{ b.disabled = false; b.textContent = '
|
|
5265
|
+
if (b) {{ b.disabled = false; b.textContent = 'Generate'; }}
|
|
4736
5266
|
}});
|
|
4737
5267
|
}});
|
|
4738
5268
|
</script>
|
|
@@ -4874,7 +5404,99 @@ def setup_routes(smx):
|
|
|
4874
5404
|
if(e.target === backdrop) closeModal();
|
|
4875
5405
|
}});
|
|
4876
5406
|
}})();
|
|
4877
|
-
|
|
5407
|
+
</script>
|
|
5408
|
+
|
|
5409
|
+
<script>
|
|
5410
|
+
// Drag & drop reordering for the "Manage Pages" list
|
|
5411
|
+
document.addEventListener('DOMContentLoaded', function () {{
|
|
5412
|
+
const list = document.querySelector('#pages .catalog-list');
|
|
5413
|
+
if (!list) return;
|
|
5414
|
+
|
|
5415
|
+
let draggingEl = null;
|
|
5416
|
+
|
|
5417
|
+
function getPageName(li) {{
|
|
5418
|
+
if (!li) return '';
|
|
5419
|
+
if (li.dataset.pageName) return li.dataset.pageName;
|
|
5420
|
+
|
|
5421
|
+
// Prefer an explicit hidden input if present
|
|
5422
|
+
const hidden = li.querySelector('input[name="page_name"]');
|
|
5423
|
+
if (hidden && hidden.value) return hidden.value.trim();
|
|
5424
|
+
|
|
5425
|
+
// Fallback: first span's text
|
|
5426
|
+
const span = li.querySelector('span');
|
|
5427
|
+
if (span && span.textContent) return span.textContent.trim();
|
|
5428
|
+
|
|
5429
|
+
return '';
|
|
5430
|
+
}}
|
|
5431
|
+
|
|
5432
|
+
// Set up draggable behaviour
|
|
5433
|
+
list.querySelectorAll('li.li-row').forEach(function (li) {{
|
|
5434
|
+
const name = getPageName(li);
|
|
5435
|
+
if (!name) return;
|
|
5436
|
+
|
|
5437
|
+
li.dataset.pageName = name;
|
|
5438
|
+
li.setAttribute('draggable', 'true');
|
|
5439
|
+
|
|
5440
|
+
li.addEventListener('dragstart', function (e) {{
|
|
5441
|
+
draggingEl = li;
|
|
5442
|
+
li.classList.add('dragging');
|
|
5443
|
+
if (e.dataTransfer) {{
|
|
5444
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
5445
|
+
e.dataTransfer.setData('text/plain', name);
|
|
5446
|
+
}}
|
|
5447
|
+
}});
|
|
5448
|
+
|
|
5449
|
+
li.addEventListener('dragend', function () {{
|
|
5450
|
+
li.classList.remove('dragging');
|
|
5451
|
+
draggingEl = null;
|
|
5452
|
+
|
|
5453
|
+
// After drop, collect new order and POST it
|
|
5454
|
+
const items = Array.from(list.querySelectorAll('li.li-row'));
|
|
5455
|
+
const names = items
|
|
5456
|
+
.map(function (node) {{ return getPageName(node); }})
|
|
5457
|
+
.filter(Boolean);
|
|
5458
|
+
|
|
5459
|
+
if (!names.length) return;
|
|
5460
|
+
|
|
5461
|
+
const fd = new FormData();
|
|
5462
|
+
fd.append('action', 'reorder_pages');
|
|
5463
|
+
fd.append('page_order_csv', names.join(','));
|
|
5464
|
+
|
|
5465
|
+
fetch('/admin', {{
|
|
5466
|
+
method: 'POST',
|
|
5467
|
+
body: fd,
|
|
5468
|
+
credentials: 'same-origin'
|
|
5469
|
+
}})
|
|
5470
|
+
.then(function (res) {{
|
|
5471
|
+
if (!res.ok) {{
|
|
5472
|
+
console.error('Failed to save page order', res.status);
|
|
5473
|
+
}}
|
|
5474
|
+
// Reload so navbar + list reflect the new order
|
|
5475
|
+
window.location.reload();
|
|
5476
|
+
}})
|
|
5477
|
+
.catch(function (err) {{
|
|
5478
|
+
console.error('Error saving page order', err);
|
|
5479
|
+
}});
|
|
5480
|
+
}});
|
|
5481
|
+
|
|
5482
|
+
li.addEventListener('dragover', function (e) {{
|
|
5483
|
+
if (!draggingEl || draggingEl === li) return;
|
|
5484
|
+
e.preventDefault();
|
|
5485
|
+
|
|
5486
|
+
const rect = li.getBoundingClientRect();
|
|
5487
|
+
const offsetY = e.clientY - rect.top;
|
|
5488
|
+
const before = offsetY < (rect.height / 2);
|
|
5489
|
+
|
|
5490
|
+
if (before) {{
|
|
5491
|
+
list.insertBefore(draggingEl, li);
|
|
5492
|
+
}}else {{
|
|
5493
|
+
list.insertBefore(draggingEl, li.nextSibling);
|
|
5494
|
+
}}
|
|
5495
|
+
}});
|
|
5496
|
+
}});
|
|
5497
|
+
}});
|
|
5498
|
+
</script>
|
|
5499
|
+
|
|
4878
5500
|
</body>
|
|
4879
5501
|
</html>
|
|
4880
5502
|
""",
|
|
@@ -4884,6 +5506,192 @@ def setup_routes(smx):
|
|
|
4884
5506
|
profiles=profiles
|
|
4885
5507
|
)
|
|
4886
5508
|
|
|
5509
|
+
|
|
5510
|
+
@smx.app.route("/admin/secretes", methods=["GET", "POST"])
|
|
5511
|
+
def admin_secretes():
|
|
5512
|
+
role = (session.get("role") or "").lower()
|
|
5513
|
+
if role not in ("admin", "superadmin"):
|
|
5514
|
+
return jsonify({"error": "forbidden"}), 403
|
|
5515
|
+
|
|
5516
|
+
if request.method == "POST":
|
|
5517
|
+
action = (request.form.get("action") or "").strip()
|
|
5518
|
+
|
|
5519
|
+
if action == "save_secret":
|
|
5520
|
+
name = (request.form.get("secret_name") or "").strip()
|
|
5521
|
+
value = (request.form.get("secret_value") or "").strip()
|
|
5522
|
+
|
|
5523
|
+
if not name:
|
|
5524
|
+
flash("Secret name is required.")
|
|
5525
|
+
return redirect(url_for("admin_secretes"))
|
|
5526
|
+
|
|
5527
|
+
# We don’t allow saving blank values accidentally.
|
|
5528
|
+
if not value:
|
|
5529
|
+
flash("Secret value is required.")
|
|
5530
|
+
return redirect(url_for("admin_secretes"))
|
|
5531
|
+
|
|
5532
|
+
db.set_secret(name, value)
|
|
5533
|
+
flash(f"Saved: {name.upper()} ✓")
|
|
5534
|
+
return redirect(url_for("admin_secretes"))
|
|
5535
|
+
|
|
5536
|
+
if action == "delete_secret":
|
|
5537
|
+
name = (request.form.get("secret_name") or "").strip()
|
|
5538
|
+
if name:
|
|
5539
|
+
db.delete_secret(name)
|
|
5540
|
+
flash(f"Deleted: {name.upper()}")
|
|
5541
|
+
return redirect(url_for("admin_secretes"))
|
|
5542
|
+
|
|
5543
|
+
# GET
|
|
5544
|
+
names = []
|
|
5545
|
+
try:
|
|
5546
|
+
names = db.list_secret_names()
|
|
5547
|
+
except Exception:
|
|
5548
|
+
names = []
|
|
5549
|
+
|
|
5550
|
+
return render_template("admin_secretes.html", secret_names=names)
|
|
5551
|
+
|
|
5552
|
+
|
|
5553
|
+
@smx.app.route("/admin/branding", methods=["GET", "POST"])
|
|
5554
|
+
@admin_required
|
|
5555
|
+
def admin_branding():
|
|
5556
|
+
branding_dir = os.path.join(_CLIENT_DIR, "branding")
|
|
5557
|
+
os.makedirs(branding_dir, exist_ok=True)
|
|
5558
|
+
|
|
5559
|
+
allowed_ext = {".png", ".jpg", ".jpeg"}
|
|
5560
|
+
max_logo_bytes = 5 * 1024 * 1024 # 5 MB
|
|
5561
|
+
max_favicon_bytes = 1 * 1024 * 1024 # 1 MB
|
|
5562
|
+
|
|
5563
|
+
def _find(base: str):
|
|
5564
|
+
for ext in (".png", ".jpg", ".jpeg"):
|
|
5565
|
+
p = os.path.join(branding_dir, f"{base}{ext}")
|
|
5566
|
+
if os.path.exists(p):
|
|
5567
|
+
return f"{base}{ext}"
|
|
5568
|
+
return None
|
|
5569
|
+
|
|
5570
|
+
def _delete_all(base: str):
|
|
5571
|
+
for ext in (".png", ".jpg", ".jpeg"):
|
|
5572
|
+
p = os.path.join(branding_dir, f"{base}{ext}")
|
|
5573
|
+
if os.path.exists(p):
|
|
5574
|
+
try:
|
|
5575
|
+
os.remove(p)
|
|
5576
|
+
except Exception:
|
|
5577
|
+
pass
|
|
5578
|
+
|
|
5579
|
+
def _save_upload(field_name: str, base: str, max_bytes: int):
|
|
5580
|
+
f = request.files.get(field_name)
|
|
5581
|
+
if not f or not f.filename:
|
|
5582
|
+
return False, None
|
|
5583
|
+
|
|
5584
|
+
ext = os.path.splitext(f.filename.lower())[1].strip()
|
|
5585
|
+
if ext not in allowed_ext:
|
|
5586
|
+
return False, f"Invalid file type for {base}. Use PNG or JPG."
|
|
5587
|
+
|
|
5588
|
+
# size check
|
|
5589
|
+
try:
|
|
5590
|
+
f.stream.seek(0, os.SEEK_END)
|
|
5591
|
+
size = f.stream.tell()
|
|
5592
|
+
f.stream.seek(0)
|
|
5593
|
+
except Exception:
|
|
5594
|
+
size = None
|
|
5595
|
+
|
|
5596
|
+
if size is not None and size > max_bytes:
|
|
5597
|
+
return False, f"{base.capitalize()} is too large. Max {max_bytes // (1024*1024)} MB."
|
|
5598
|
+
|
|
5599
|
+
# Replace existing logo.* / favicon.*
|
|
5600
|
+
_delete_all(base)
|
|
5601
|
+
|
|
5602
|
+
out_path = os.path.join(branding_dir, f"{base}{ext}")
|
|
5603
|
+
try:
|
|
5604
|
+
f.save(out_path)
|
|
5605
|
+
except Exception as e:
|
|
5606
|
+
return False, f"Failed to save {base}: {e}"
|
|
5607
|
+
|
|
5608
|
+
return True, None
|
|
5609
|
+
|
|
5610
|
+
# POST actions
|
|
5611
|
+
if request.method == "POST":
|
|
5612
|
+
action = (request.form.get("action") or "upload").strip().lower()
|
|
5613
|
+
|
|
5614
|
+
if action == "reset":
|
|
5615
|
+
_delete_all("logo")
|
|
5616
|
+
_delete_all("favicon")
|
|
5617
|
+
try:
|
|
5618
|
+
smx._apply_branding_from_disk()
|
|
5619
|
+
except Exception:
|
|
5620
|
+
pass
|
|
5621
|
+
flash("Branding reset to defaults ✓")
|
|
5622
|
+
return redirect(url_for("admin_branding"))
|
|
5623
|
+
|
|
5624
|
+
ok1, err1 = _save_upload("logo_file", "logo", max_logo_bytes)
|
|
5625
|
+
ok2, err2 = _save_upload("favicon_file", "favicon", max_favicon_bytes)
|
|
5626
|
+
|
|
5627
|
+
if err1:
|
|
5628
|
+
flash(err1, "error")
|
|
5629
|
+
if err2:
|
|
5630
|
+
flash(err2, "error")
|
|
5631
|
+
|
|
5632
|
+
if ok1 or ok2:
|
|
5633
|
+
try:
|
|
5634
|
+
smx._apply_branding_from_disk()
|
|
5635
|
+
except Exception:
|
|
5636
|
+
pass
|
|
5637
|
+
flash("Branding updated ✓")
|
|
5638
|
+
|
|
5639
|
+
return redirect(url_for("admin_branding"))
|
|
5640
|
+
|
|
5641
|
+
# GET: show current status
|
|
5642
|
+
logo_fn = _find("logo")
|
|
5643
|
+
fav_fn = _find("favicon")
|
|
5644
|
+
|
|
5645
|
+
cache_bust = int(time.time())
|
|
5646
|
+
|
|
5647
|
+
logo_url = f"/branding/{logo_fn}?v={cache_bust}" if logo_fn else None
|
|
5648
|
+
favicon_url = f"/branding/{fav_fn}?v={cache_bust}" if fav_fn else None
|
|
5649
|
+
|
|
5650
|
+
default_logo_html = getattr(smx, "_default_site_logo", smx.site_logo)
|
|
5651
|
+
default_favicon_url = getattr(smx, "_default_favicon", smx.favicon)
|
|
5652
|
+
|
|
5653
|
+
return render_template(
|
|
5654
|
+
"admin_branding.html",
|
|
5655
|
+
logo_url=logo_url,
|
|
5656
|
+
favicon_url=favicon_url,
|
|
5657
|
+
default_logo_html=Markup(default_logo_html),
|
|
5658
|
+
default_favicon_url=default_favicon_url,
|
|
5659
|
+
)
|
|
5660
|
+
|
|
5661
|
+
|
|
5662
|
+
@smx.app.route("/admin/features", methods=["GET", "POST"])
|
|
5663
|
+
@admin_required
|
|
5664
|
+
def admin_features():
|
|
5665
|
+
# Defaults from DB (or fall back)
|
|
5666
|
+
def _truthy(v):
|
|
5667
|
+
return str(v or "").strip().lower() in ("1", "true", "yes", "on")
|
|
5668
|
+
|
|
5669
|
+
if request.method == "POST":
|
|
5670
|
+
stream_on = "1" if request.form.get("stream_mode") == "on" else "0"
|
|
5671
|
+
user_files_on = "1" if request.form.get("user_files") == "on" else "0"
|
|
5672
|
+
|
|
5673
|
+
db.set_setting("feature.stream_mode", stream_on)
|
|
5674
|
+
db.set_setting("feature.user_files", user_files_on)
|
|
5675
|
+
|
|
5676
|
+
# Apply immediately (no restart)
|
|
5677
|
+
try:
|
|
5678
|
+
smx._apply_feature_flags_from_db()
|
|
5679
|
+
except Exception:
|
|
5680
|
+
pass
|
|
5681
|
+
|
|
5682
|
+
flash("Settings updated ✓")
|
|
5683
|
+
return redirect(url_for("admin_features"))
|
|
5684
|
+
|
|
5685
|
+
stream_mode = _truthy(db.get_setting("feature.stream_mode", "0"))
|
|
5686
|
+
user_files = _truthy(db.get_setting("feature.user_files", "0"))
|
|
5687
|
+
|
|
5688
|
+
return render_template(
|
|
5689
|
+
"admin_features.html",
|
|
5690
|
+
stream_mode=stream_mode,
|
|
5691
|
+
user_files=user_files,
|
|
5692
|
+
)
|
|
5693
|
+
|
|
5694
|
+
|
|
4887
5695
|
@smx.app.route("/admin/delete.json", methods=["POST"])
|
|
4888
5696
|
def admin_delete_universal():
|
|
4889
5697
|
|
|
@@ -5065,47 +5873,56 @@ def setup_routes(smx):
|
|
|
5065
5873
|
smx.warning(f"/admin/delete.json error: {e}")
|
|
5066
5874
|
return jsonify(ok=False, error=str(e)), 500
|
|
5067
5875
|
|
|
5068
|
-
|
|
5876
|
+
|
|
5069
5877
|
@smx.app.route('/page/<page_name>')
|
|
5070
5878
|
def view_page(page_name):
|
|
5071
|
-
|
|
5072
|
-
|
|
5073
|
-
|
|
5074
|
-
|
|
5075
|
-
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
5879
|
+
hero_fix_css = """
|
|
5880
|
+
<style>
|
|
5881
|
+
div[id^="smx-page-"] .hero-overlay{
|
|
5882
|
+
background:linear-gradient(90deg,
|
|
5883
|
+
rgba(2,6,23,.62) 0%,
|
|
5884
|
+
rgba(2,6,23,.40) 42%,
|
|
5885
|
+
rgba(2,6,23,.14) 72%,
|
|
5886
|
+
rgba(2,6,23,.02) 100%
|
|
5887
|
+
) !important;
|
|
5888
|
+
}
|
|
5889
|
+
@media (max-width: 860px){
|
|
5890
|
+
div[id^="smx-page-"] .hero-overlay{
|
|
5891
|
+
background:linear-gradient(180deg,
|
|
5892
|
+
rgba(2,6,23,.16) 0%,
|
|
5893
|
+
rgba(2,6,23,.55) 70%,
|
|
5894
|
+
rgba(2,6,23,.70) 100%
|
|
5895
|
+
) !important;
|
|
5896
|
+
}
|
|
5897
|
+
}
|
|
5898
|
+
div[id^="smx-page-"] .hero-panel{
|
|
5899
|
+
background:rgba(2,6,23,.24) !important;
|
|
5900
|
+
backdrop-filter: blur(4px) !important;
|
|
5901
|
+
-webkit-backdrop-filter: blur(4px) !important;
|
|
5902
|
+
}
|
|
5903
|
+
</style>
|
|
5904
|
+
"""
|
|
5095
5905
|
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5906
|
+
smx.page = page_name.lower()
|
|
5907
|
+
nav_html = _generate_nav()
|
|
5908
|
+
# Always fetch the latest HTML from disk/DB (prevents stale cache across workers)
|
|
5909
|
+
content = db.get_page_html(page_name)
|
|
5910
|
+
if content is None:
|
|
5911
|
+
content = smx.pages.get(page_name, f"No content found for page '{page_name}'.")
|
|
5912
|
+
|
|
5913
|
+
view_page_html = f"""
|
|
5914
|
+
{head_html()}
|
|
5915
|
+
{nav_html}
|
|
5916
|
+
<main style="padding-top:calc(52px + env(safe-area-inset-top)); width:100%; box-sizing:border-box;">
|
|
5917
|
+
{content}
|
|
5918
|
+
</main>
|
|
5919
|
+
{hero_fix_css}
|
|
5920
|
+
{footer_html()}
|
|
5921
|
+
"""
|
|
5922
|
+
resp = Response(view_page_html, mimetype="text/html")
|
|
5923
|
+
# Prevent the browser/proxies from keeping an old copy during active editing/publishing
|
|
5924
|
+
resp.headers["Cache-Control"] = "no-store"
|
|
5925
|
+
return resp
|
|
5109
5926
|
|
|
5110
5927
|
|
|
5111
5928
|
@smx.app.route('/docs')
|
|
@@ -5165,7 +5982,6 @@ def setup_routes(smx):
|
|
|
5165
5982
|
html += "</table>"
|
|
5166
5983
|
return html
|
|
5167
5984
|
|
|
5168
|
-
|
|
5169
5985
|
@smx.app.route("/admin/chunks/edit/<int:chunk_id>", methods=["GET", "POST"])
|
|
5170
5986
|
def edit_chunk(chunk_id):
|
|
5171
5987
|
if request.method == "POST":
|
|
@@ -5200,99 +6016,237 @@ def setup_routes(smx):
|
|
|
5200
6016
|
def edit_page(page_name):
|
|
5201
6017
|
if request.method == "POST":
|
|
5202
6018
|
new_page_name = request.form.get("page_name", "").strip()
|
|
5203
|
-
|
|
6019
|
+
# Keep page_content formatting exactly as typed
|
|
6020
|
+
new_content = request.form.get("page_content", "")
|
|
6021
|
+
|
|
5204
6022
|
if page_name in smx.pages and new_page_name:
|
|
5205
6023
|
db.update_page(page_name, new_page_name, new_content)
|
|
6024
|
+
smx.pages = db.get_pages()
|
|
5206
6025
|
return redirect(url_for("admin_panel"))
|
|
5207
|
-
|
|
5208
|
-
content =
|
|
5209
|
-
|
|
5210
|
-
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
5220
|
-
|
|
5221
|
-
.
|
|
5222
|
-
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
5228
|
-
}
|
|
5229
|
-
input, textarea {
|
|
5230
|
-
width: 100%;
|
|
5231
|
-
margin: 10px 0;
|
|
5232
|
-
padding: 10px;
|
|
5233
|
-
border: 1px solid #ccc;
|
|
5234
|
-
border-radius: 4px;
|
|
5235
|
-
}
|
|
5236
|
-
button {
|
|
5237
|
-
padding: 10px 20px;
|
|
5238
|
-
background: #007acc;
|
|
5239
|
-
border: none;
|
|
5240
|
-
color: #fff;
|
|
5241
|
-
border-radius: 4px;
|
|
5242
|
-
cursor: pointer;
|
|
5243
|
-
}
|
|
5244
|
-
button:hover {
|
|
5245
|
-
background: #005fa3;
|
|
5246
|
-
}
|
|
5247
|
-
a.button {
|
|
5248
|
-
padding: 10px 20px;
|
|
5249
|
-
background: #aaa;
|
|
5250
|
-
border: none;
|
|
5251
|
-
color: #fff;
|
|
5252
|
-
border-radius: 4px;
|
|
5253
|
-
text-decoration: none;
|
|
5254
|
-
}
|
|
5255
|
-
a.button:hover {
|
|
5256
|
-
background: #888;
|
|
5257
|
-
}
|
|
5258
|
-
</style>
|
|
5259
|
-
</head>
|
|
5260
|
-
<body>
|
|
5261
|
-
<div class="editor">
|
|
5262
|
-
<h1>Edit Page - {{ page_name }}</h1>
|
|
5263
|
-
<form method="post">
|
|
5264
|
-
<input type="text" name="page_name" value="{{ page_name }}" required>
|
|
5265
|
-
<textarea name="page_content" rows="20">{{ content }}</textarea>
|
|
5266
|
-
<div style="margin-top:15px;">
|
|
5267
|
-
<button type="submit">Update Page</button>
|
|
5268
|
-
<a class="button" href="{{ url_for('admin_panel') }}">Cancel</a>
|
|
5269
|
-
</div>
|
|
5270
|
-
</form>
|
|
5271
|
-
</div>
|
|
5272
|
-
</body>
|
|
5273
|
-
</html>
|
|
5274
|
-
""", page_name=page_name, content=content)
|
|
6026
|
+
|
|
6027
|
+
content = db.get_page_html(page_name) or ""
|
|
6028
|
+
|
|
6029
|
+
# NEW: builder layout json (stored separately)
|
|
6030
|
+
layout_row = getattr(db, "get_page_layout", None)
|
|
6031
|
+
layout_json = None
|
|
6032
|
+
if callable(layout_row):
|
|
6033
|
+
try:
|
|
6034
|
+
row = db.get_page_layout(page_name)
|
|
6035
|
+
layout_json = (row or {}).get("layout_json")
|
|
6036
|
+
except Exception:
|
|
6037
|
+
layout_json = None
|
|
6038
|
+
published_as = request.args.get("published_as")
|
|
6039
|
+
return render_template(
|
|
6040
|
+
"edit_page.html",
|
|
6041
|
+
page_name=page_name,
|
|
6042
|
+
content=content,
|
|
6043
|
+
layout_json=layout_json,
|
|
6044
|
+
published_as=published_as,
|
|
6045
|
+
)
|
|
5275
6046
|
|
|
6047
|
+
|
|
6048
|
+
# ────────────────────────────────────────────────────
|
|
6049
|
+
# PIXABAY
|
|
6050
|
+
# ────────────────────────────────────────────────────
|
|
6051
|
+
@smx.app.route("/admin/pixabay/search.json", methods=["GET"])
|
|
6052
|
+
def admin_pixabay_search():
|
|
6053
|
+
role = (session.get("role") or "").lower()
|
|
6054
|
+
if role not in ("admin", "superadmin"):
|
|
6055
|
+
return jsonify({"error": "forbidden"}), 403
|
|
6056
|
+
|
|
6057
|
+
q = (request.args.get("q") or "").strip()
|
|
6058
|
+
orientation = (request.args.get("orientation") or "horizontal").strip().lower()
|
|
6059
|
+
image_type = (request.args.get("image_type") or "photo").strip().lower()
|
|
6060
|
+
|
|
6061
|
+
api_key = None
|
|
6062
|
+
try:
|
|
6063
|
+
api_key = db.get_secret("PIXABAY_API_KEY")
|
|
6064
|
+
except Exception:
|
|
6065
|
+
api_key = None
|
|
6066
|
+
|
|
6067
|
+
if not api_key:
|
|
6068
|
+
return jsonify({"error": "Missing PIXABAY_API_KEY. Add it in Admin → Manage secretes."}), 400
|
|
6069
|
+
|
|
6070
|
+
try:
|
|
6071
|
+
from syntaxmatrix.media.media_pixabay import pixabay_search
|
|
6072
|
+
hits = pixabay_search(
|
|
6073
|
+
api_key=api_key,
|
|
6074
|
+
query=q,
|
|
6075
|
+
image_type=image_type,
|
|
6076
|
+
orientation=orientation,
|
|
6077
|
+
per_page=24,
|
|
6078
|
+
safesearch=True,
|
|
6079
|
+
editors_choice=False,
|
|
6080
|
+
min_width=960,
|
|
6081
|
+
)
|
|
6082
|
+
payload = []
|
|
6083
|
+
for h in hits:
|
|
6084
|
+
payload.append({
|
|
6085
|
+
"id": h.id,
|
|
6086
|
+
"page_url": h.page_url,
|
|
6087
|
+
"preview_url": h.preview_url,
|
|
6088
|
+
"large_image_url": h.large_image_url,
|
|
6089
|
+
"webformat_url": h.webformat_url,
|
|
6090
|
+
"width": h.width,
|
|
6091
|
+
"height": h.height,
|
|
6092
|
+
"tags": h.tags,
|
|
6093
|
+
"user": h.user,
|
|
6094
|
+
"type": h.image_type
|
|
6095
|
+
})
|
|
6096
|
+
return jsonify({"items": payload}), 200
|
|
6097
|
+
|
|
6098
|
+
except Exception as e:
|
|
6099
|
+
return jsonify({"error": str(e)}), 500
|
|
6100
|
+
|
|
6101
|
+
@smx.app.route("/admin/pixabay/import.json", methods=["POST"])
|
|
6102
|
+
def admin_pixabay_import():
|
|
6103
|
+
role = (session.get("role") or "").lower()
|
|
6104
|
+
if role not in ("admin", "superadmin"):
|
|
6105
|
+
return jsonify({"error": "forbidden"}), 403
|
|
6106
|
+
|
|
6107
|
+
api_key = None
|
|
6108
|
+
try:
|
|
6109
|
+
api_key = db.get_secret("PIXABAY_API_KEY")
|
|
6110
|
+
except Exception:
|
|
6111
|
+
api_key = None
|
|
6112
|
+
|
|
6113
|
+
if not api_key:
|
|
6114
|
+
return jsonify({"error": "Missing PIXABAY_API_KEY. Add it in Admin → Manage secretes."}), 400
|
|
6115
|
+
|
|
6116
|
+
payload = request.get_json(force=True) or {}
|
|
6117
|
+
pixabay_id = payload.get("id")
|
|
6118
|
+
if not pixabay_id:
|
|
6119
|
+
return jsonify({"error": "Missing id"}), 400
|
|
6120
|
+
|
|
6121
|
+
min_width = int(payload.get("min_width") or 0)
|
|
6122
|
+
min_width = max(0, min(3000, min_width))
|
|
6123
|
+
|
|
6124
|
+
try:
|
|
6125
|
+
import requests
|
|
6126
|
+
from syntaxmatrix.media.media_pixabay import PixabayHit, import_pixabay_hit
|
|
6127
|
+
|
|
6128
|
+
# Look up the hit by ID from Pixabay API (prevents client tampering)
|
|
6129
|
+
r = requests.get(
|
|
6130
|
+
"https://pixabay.com/api/",
|
|
6131
|
+
params={"key": api_key, "id": str(pixabay_id)},
|
|
6132
|
+
timeout=15
|
|
6133
|
+
)
|
|
6134
|
+
r.raise_for_status()
|
|
6135
|
+
data = r.json() or {}
|
|
6136
|
+
hits = data.get("hits") or []
|
|
6137
|
+
if not hits:
|
|
6138
|
+
return jsonify({"error": "Pixabay image not found"}), 404
|
|
6139
|
+
|
|
6140
|
+
h = hits[0]
|
|
6141
|
+
hit = PixabayHit(
|
|
6142
|
+
id=int(h.get("id")),
|
|
6143
|
+
page_url=str(h.get("pageURL") or ""),
|
|
6144
|
+
tags=str(h.get("tags") or ""),
|
|
6145
|
+
user=str(h.get("user") or ""),
|
|
6146
|
+
preview_url=str(h.get("previewURL") or ""),
|
|
6147
|
+
webformat_url=str(h.get("webformatURL") or ""),
|
|
6148
|
+
large_image_url=str(h.get("largeImageURL") or ""),
|
|
6149
|
+
width=int(h.get("imageWidth") or 0),
|
|
6150
|
+
height=int(h.get("imageHeight") or 0),
|
|
6151
|
+
image_type=str(h.get("type") or "photo"),
|
|
6152
|
+
)
|
|
6153
|
+
|
|
6154
|
+
# Paths
|
|
6155
|
+
media_dir = os.path.join(_CLIENT_DIR, "uploads", "media")
|
|
6156
|
+
imported_dir = os.path.join(media_dir, "images", "imported")
|
|
6157
|
+
thumbs_dir = os.path.join(media_dir, "images", "thumbs")
|
|
6158
|
+
os.makedirs(imported_dir, exist_ok=True)
|
|
6159
|
+
os.makedirs(thumbs_dir, exist_ok=True)
|
|
6160
|
+
|
|
6161
|
+
# Download-once guard: if already imported, reuse local file
|
|
6162
|
+
existing_jpg = os.path.join(imported_dir, f"pixabay-{hit.id}.jpg")
|
|
6163
|
+
existing_png = os.path.join(imported_dir, f"pixabay-{hit.id}.png")
|
|
6164
|
+
|
|
6165
|
+
if os.path.exists(existing_jpg) or os.path.exists(existing_png):
|
|
6166
|
+
existing_abs = existing_png if os.path.exists(existing_png) else existing_jpg
|
|
6167
|
+
rel_path = os.path.relpath(existing_abs, media_dir).replace("\\", "/")
|
|
6168
|
+
return jsonify({
|
|
6169
|
+
"rel_path": rel_path,
|
|
6170
|
+
"url": url_for("serve_media", filename=rel_path),
|
|
6171
|
+
"thumb_url": None,
|
|
6172
|
+
"source_url": hit.page_url,
|
|
6173
|
+
"author": hit.user,
|
|
6174
|
+
"tags": hit.tags,
|
|
6175
|
+
}), 200
|
|
6176
|
+
|
|
6177
|
+
meta = import_pixabay_hit(
|
|
6178
|
+
hit,
|
|
6179
|
+
media_images_dir=imported_dir,
|
|
6180
|
+
thumbs_dir=thumbs_dir,
|
|
6181
|
+
max_width=1920,
|
|
6182
|
+
thumb_width=800,
|
|
6183
|
+
min_width=min_width
|
|
6184
|
+
)
|
|
6185
|
+
|
|
6186
|
+
# Convert absolute paths to rel paths + URLs
|
|
6187
|
+
rel_path = os.path.relpath(meta["file_path"], media_dir).replace("\\", "/")
|
|
6188
|
+
thumb_rel = None
|
|
6189
|
+
if meta.get("thumb_path"):
|
|
6190
|
+
thumb_rel = os.path.relpath(meta["thumb_path"], media_dir).replace("\\", "/")
|
|
6191
|
+
|
|
6192
|
+
# Register in DB (for local-first & Media sources)
|
|
6193
|
+
try:
|
|
6194
|
+
db.upsert_media_asset(
|
|
6195
|
+
rel_path=rel_path,
|
|
6196
|
+
kind="image",
|
|
6197
|
+
thumb_path=thumb_rel,
|
|
6198
|
+
sha256=meta.get("sha256"),
|
|
6199
|
+
dhash=meta.get("dhash"),
|
|
6200
|
+
width=int(meta.get("width") or 0),
|
|
6201
|
+
height=int(meta.get("height") or 0),
|
|
6202
|
+
mime=meta.get("mime"),
|
|
6203
|
+
source="pixabay",
|
|
6204
|
+
source_url=meta.get("source_url"),
|
|
6205
|
+
author=meta.get("author"),
|
|
6206
|
+
licence="Pixabay Content Licence",
|
|
6207
|
+
tags=meta.get("tags"),
|
|
6208
|
+
)
|
|
6209
|
+
except Exception:
|
|
6210
|
+
pass
|
|
6211
|
+
|
|
6212
|
+
return jsonify({
|
|
6213
|
+
"rel_path": rel_path,
|
|
6214
|
+
"url": url_for("serve_media", filename=rel_path),
|
|
6215
|
+
"thumb_url": url_for("serve_media", filename=thumb_rel) if thumb_rel else None,
|
|
6216
|
+
"source_url": meta.get("source_url"),
|
|
6217
|
+
"author": meta.get("author"),
|
|
6218
|
+
"tags": meta.get("tags"),
|
|
6219
|
+
}), 200
|
|
6220
|
+
|
|
6221
|
+
except Exception as e:
|
|
6222
|
+
return jsonify({"error": str(e)}), 500
|
|
6223
|
+
|
|
6224
|
+
|
|
5276
6225
|
# ────────────────────────────────────────────────────
|
|
5277
6226
|
# ACCOUNTS
|
|
5278
6227
|
# ────────────────────────────────────────────────────
|
|
5279
6228
|
# ----Register ---------------------------------------
|
|
5280
6229
|
@smx.app.route("/register", methods=["GET", "POST"])
|
|
5281
6230
|
def register():
|
|
6231
|
+
|
|
6232
|
+
# If the consumer app has not enabled registration, redirect to login.
|
|
6233
|
+
if not getattr(smx, "registration_enabled", False):
|
|
6234
|
+
return redirect(url_for("login"))
|
|
6235
|
+
|
|
5282
6236
|
if request.method == "POST":
|
|
5283
|
-
|
|
5284
|
-
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
|
|
5288
|
-
|
|
5289
|
-
|
|
5290
|
-
|
|
5291
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
6237
|
+
email = request.form["email"].strip()
|
|
6238
|
+
username = request.form["username"].strip()
|
|
6239
|
+
password = request.form["password"]
|
|
6240
|
+
role = request.form.get("role", "user")
|
|
6241
|
+
if not email or not password:
|
|
6242
|
+
flash("email and password required.")
|
|
6243
|
+
else:
|
|
6244
|
+
success = register_user(email, username, password, role)
|
|
6245
|
+
if success:
|
|
6246
|
+
flash("Registration successful—please log in.")
|
|
6247
|
+
return redirect(url_for("login"))
|
|
6248
|
+
else:
|
|
6249
|
+
flash("Email already taken.")
|
|
5296
6250
|
return render_template("register.html")
|
|
5297
6251
|
|
|
5298
6252
|
# ----- Login --------------------------------------------
|
|
@@ -5309,8 +6263,15 @@ def setup_routes(smx):
|
|
|
5309
6263
|
session["username"] = user["username"]
|
|
5310
6264
|
session["role"] = user["role"]
|
|
5311
6265
|
|
|
5312
|
-
#
|
|
5313
|
-
#
|
|
6266
|
+
# If this account was created with a temporary password,
|
|
6267
|
+
# force them through the change-password flow first.
|
|
6268
|
+
if _auth.user_must_reset_password(user["id"]):
|
|
6269
|
+
session["must_reset_password"] = True
|
|
6270
|
+
flash("Please set a new password before continuing.", "warning")
|
|
6271
|
+
return redirect(url_for("change_password"))
|
|
6272
|
+
|
|
6273
|
+
# Clear any stale flag for accounts that no longer need a reset
|
|
6274
|
+
session.pop("must_reset_password", None)
|
|
5314
6275
|
|
|
5315
6276
|
# — Load past chats from chats.db for this user —
|
|
5316
6277
|
chat_ids = SQLHistoryStore.list_chats(user["id"])
|
|
@@ -5341,7 +6302,40 @@ def setup_routes(smx):
|
|
|
5341
6302
|
flash("Invalid username or password.")
|
|
5342
6303
|
return render_template("login.html")
|
|
5343
6304
|
|
|
5344
|
-
|
|
6305
|
+
|
|
6306
|
+
@smx.app.route("/change_password", methods=["GET", "POST"])
|
|
6307
|
+
@login_required
|
|
6308
|
+
def change_password():
|
|
6309
|
+
user_id = session.get("user_id")
|
|
6310
|
+
if not user_id:
|
|
6311
|
+
flash("Please log in again.", "error")
|
|
6312
|
+
return redirect(url_for("login"))
|
|
6313
|
+
|
|
6314
|
+
if request.method == "POST":
|
|
6315
|
+
current = (request.form.get("current_password") or "").strip()
|
|
6316
|
+
new1 = (request.form.get("new_password") or "").strip()
|
|
6317
|
+
new2 = (request.form.get("confirm_password") or "").strip()
|
|
6318
|
+
|
|
6319
|
+
if not new1:
|
|
6320
|
+
flash("New password is required.", "error")
|
|
6321
|
+
elif new1 != new2:
|
|
6322
|
+
flash("New passwords do not match.", "error")
|
|
6323
|
+
elif not _auth.verify_password(user_id, current):
|
|
6324
|
+
flash("Current password is incorrect.", "error")
|
|
6325
|
+
else:
|
|
6326
|
+
# Update password + clear the mandatory-reset flag
|
|
6327
|
+
_auth.update_password(user_id, new1)
|
|
6328
|
+
_auth.clear_must_reset(user_id)
|
|
6329
|
+
session.pop("must_reset_password", None)
|
|
6330
|
+
flash("Password updated successfully.", "success")
|
|
6331
|
+
|
|
6332
|
+
next_url = request.args.get("next") or url_for("dashboard")
|
|
6333
|
+
return redirect(next_url)
|
|
6334
|
+
|
|
6335
|
+
return render_template("change_password.html")
|
|
6336
|
+
|
|
6337
|
+
|
|
6338
|
+
# ----- Logout -------------------------------------------
|
|
5345
6339
|
@smx.app.route("/logout", methods=["POST"])
|
|
5346
6340
|
def logout():
|
|
5347
6341
|
"""Clear session and return to login."""
|
|
@@ -5367,26 +6361,240 @@ def setup_routes(smx):
|
|
|
5367
6361
|
return any(r in ("admin", "superadmin") for r in roles if r)
|
|
5368
6362
|
return dict(can_see_admin=can_see_admin)
|
|
5369
6363
|
|
|
6364
|
+
|
|
6365
|
+
def _is_admin_request() -> bool:
|
|
6366
|
+
r = (session.get("role") or "").lower()
|
|
6367
|
+
if r in ("admin", "superadmin"):
|
|
6368
|
+
return True
|
|
6369
|
+
|
|
6370
|
+
# Fallback to Flask-Login user roles (matches your inject_role_helpers logic)
|
|
6371
|
+
if not getattr(current_user, "is_authenticated", False):
|
|
6372
|
+
return False
|
|
6373
|
+
|
|
6374
|
+
roles = getattr(current_user, "roles", None)
|
|
6375
|
+
if roles is None:
|
|
6376
|
+
rr = getattr(current_user, "role", None)
|
|
6377
|
+
roles = [rr] if rr else []
|
|
6378
|
+
|
|
6379
|
+
return any((str(x or "")).lower() in ("admin", "superadmin") for x in roles)
|
|
6380
|
+
|
|
6381
|
+
|
|
6382
|
+
@smx.app.route("/admin/page_layouts/<page_name>", methods=["GET", "POST"])
|
|
6383
|
+
def page_layouts_api(page_name):
|
|
6384
|
+
if not _is_admin_request():
|
|
6385
|
+
return jsonify({"error": "forbidden"}), 403
|
|
6386
|
+
|
|
6387
|
+
if request.method == "GET":
|
|
6388
|
+
try:
|
|
6389
|
+
row = db.get_page_layout(page_name) or {}
|
|
6390
|
+
return jsonify(row), 200
|
|
6391
|
+
except Exception as e:
|
|
6392
|
+
return jsonify({"error": str(e)}), 500
|
|
6393
|
+
|
|
6394
|
+
# POST: save layout json
|
|
6395
|
+
from syntaxmatrix.page_layout_contract import normalise_layout, validate_layout
|
|
6396
|
+
payload = request.get_json(force=True) or {}
|
|
6397
|
+
payload = normalise_layout(payload, mode="draft")
|
|
6398
|
+
|
|
6399
|
+
issues = validate_layout(payload)
|
|
6400
|
+
errors = [i.to_dict() for i in issues if i.level == "error"]
|
|
6401
|
+
warnings = [i.to_dict() for i in issues if i.level == "warning"]
|
|
6402
|
+
|
|
6403
|
+
if errors:
|
|
6404
|
+
return jsonify({"error": "invalid layout", "issues": errors, "warnings": warnings}), 400
|
|
6405
|
+
|
|
6406
|
+
layout_json = json.dumps(payload, ensure_ascii=False)
|
|
6407
|
+
db.upsert_page_layout(page_name, layout_json)
|
|
6408
|
+
return jsonify({"ok": True, "warnings": warnings}), 200
|
|
6409
|
+
|
|
6410
|
+
|
|
6411
|
+
@smx.app.route("/admin/page_layouts/<page_name>/publish", methods=["POST"])
|
|
6412
|
+
def publish_layout_patch_only(page_name):
|
|
6413
|
+
|
|
6414
|
+
role = (session.get("role") or "").lower()
|
|
6415
|
+
if role not in ("admin", "superadmin"):
|
|
6416
|
+
return jsonify({"error": "forbidden"}), 403
|
|
6417
|
+
|
|
6418
|
+
try:
|
|
6419
|
+
# Load layout (prefer request body; fallback to DB)
|
|
6420
|
+
payload = request.get_json(silent=True) or {}
|
|
6421
|
+
if not (isinstance(payload, dict) and isinstance(payload.get("sections"), list)):
|
|
6422
|
+
row = db.get_page_layout(page_name) or {}
|
|
6423
|
+
raw = (row or {}).get("layout_json") or ""
|
|
6424
|
+
payload = json.loads(raw) if raw else {}
|
|
6425
|
+
|
|
6426
|
+
if not (isinstance(payload, dict) and isinstance(payload.get("sections"), list)):
|
|
6427
|
+
return jsonify({"error": "no layout to publish"}), 400
|
|
6428
|
+
|
|
6429
|
+
# Always patch the latest HTML on disk/DB (avoids stale smx.pages in other workers)
|
|
6430
|
+
existing_html = db.get_page_html(page_name) or ""
|
|
6431
|
+
if not existing_html:
|
|
6432
|
+
# Fallback only (older behaviour)
|
|
6433
|
+
if not isinstance(smx.pages, dict):
|
|
6434
|
+
smx.pages = db.get_pages()
|
|
6435
|
+
page_key = (page_name or "").strip()
|
|
6436
|
+
existing_html = smx.pages.get(page_key) or smx.pages.get(page_key.lower()) or ""
|
|
6437
|
+
|
|
6438
|
+
# Keep a copy of what was originally stored so we can correctly detect changes
|
|
6439
|
+
original_html = existing_html
|
|
6440
|
+
|
|
6441
|
+
# NEW: ensure any newly-added layout sections exist in the stored HTML
|
|
6442
|
+
# so validate_compiled_html won't reject the publish.
|
|
6443
|
+
existing_html, inserted_sections = ensure_sections_exist(
|
|
6444
|
+
existing_html,
|
|
6445
|
+
payload,
|
|
6446
|
+
page_slug=page_name
|
|
6447
|
+
)
|
|
6448
|
+
|
|
6449
|
+
if not existing_html:
|
|
6450
|
+
return jsonify({"error": "page html not found"}), 404
|
|
6451
|
+
|
|
6452
|
+
payload = normalise_layout(payload, mode="prod")
|
|
6453
|
+
|
|
6454
|
+
issues = validate_layout(payload)
|
|
6455
|
+
errors = [i.to_dict() for i in issues if i.level == "error"]
|
|
6456
|
+
warnings = [i.to_dict() for i in issues if i.level == "warning"]
|
|
6457
|
+
|
|
6458
|
+
if errors:
|
|
6459
|
+
return jsonify({"error": "invalid layout", "issues": errors, "warnings": warnings}), 400
|
|
6460
|
+
|
|
6461
|
+
# Optional but very useful: validate current HTML has the anchors we need
|
|
6462
|
+
html_issues = validate_compiled_html(existing_html, payload)
|
|
6463
|
+
html_errors = [i.to_dict() for i in html_issues if i.level == "error"]
|
|
6464
|
+
html_warnings = [i.to_dict() for i in html_issues if i.level == "warning"]
|
|
6465
|
+
|
|
6466
|
+
if html_errors:
|
|
6467
|
+
return jsonify({"error": "html not compatible with patching", "issues": html_errors, "warnings": html_warnings}), 400
|
|
6468
|
+
|
|
6469
|
+
updated_html, stats = patch_page_publish(existing_html, payload, page_slug=page_name)
|
|
6470
|
+
|
|
6471
|
+
# If nothing changed, still return ok
|
|
6472
|
+
if updated_html == original_html:
|
|
6473
|
+
return jsonify({"ok": True, "mode": "noop", "stats": stats}), 200
|
|
6474
|
+
|
|
6475
|
+
# Persist patched HTML
|
|
6476
|
+
db.update_page(page_name, page_name, updated_html)
|
|
6477
|
+
smx.pages = db.get_pages()
|
|
6478
|
+
|
|
6479
|
+
return jsonify({"ok": True, "mode": "patched", "stats": stats}), 200
|
|
6480
|
+
|
|
6481
|
+
except Exception as e:
|
|
6482
|
+
smx.warning(f"publish_layout_patch_only error: {e}")
|
|
6483
|
+
return jsonify({"error": str(e)}), 500
|
|
6484
|
+
|
|
6485
|
+
|
|
6486
|
+
@smx.app.route("/admin/page_layouts/<page_name>/compile", methods=["POST"])
|
|
6487
|
+
def compile_page_layout(page_name):
|
|
6488
|
+
role = (session.get("role") or "").lower()
|
|
6489
|
+
if role not in ("admin", "superadmin"):
|
|
6490
|
+
return jsonify({"error": "forbidden"}), 403
|
|
6491
|
+
|
|
6492
|
+
try:
|
|
6493
|
+
payload = request.get_json(force=True) or {}
|
|
6494
|
+
html_doc = compile_layout_to_html(payload, page_slug=page_name)
|
|
6495
|
+
return jsonify({"html": html_doc}), 200
|
|
6496
|
+
except Exception as e:
|
|
6497
|
+
return jsonify({"error": str(e)}), 500
|
|
6498
|
+
|
|
6499
|
+
|
|
6500
|
+
@smx.app.route("/admin/media/list.json", methods=["GET"])
|
|
6501
|
+
def list_media_json():
|
|
6502
|
+
role = (session.get("role") or "").lower()
|
|
6503
|
+
if role not in ("admin", "superadmin"):
|
|
6504
|
+
return jsonify({"error": "forbidden"}), 403
|
|
6505
|
+
|
|
6506
|
+
media_dir = os.path.join(_CLIENT_DIR, "uploads", "media")
|
|
6507
|
+
items = []
|
|
6508
|
+
img_ext = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
|
6509
|
+
vid_ext = {".mp4", ".webm", ".mov", ".m4v"}
|
|
6510
|
+
|
|
6511
|
+
for root, _, files in os.walk(media_dir):
|
|
6512
|
+
for fn in files:
|
|
6513
|
+
abs_path = os.path.join(root, fn)
|
|
6514
|
+
rel = os.path.relpath(abs_path, media_dir).replace("\\", "/")
|
|
6515
|
+
ext = os.path.splitext(fn.lower())[1]
|
|
6516
|
+
kind = "other"
|
|
6517
|
+
if ext in img_ext:
|
|
6518
|
+
kind = "image"
|
|
6519
|
+
elif ext in vid_ext:
|
|
6520
|
+
kind = "video"
|
|
6521
|
+
items.append({
|
|
6522
|
+
"name": fn,
|
|
6523
|
+
"path": rel,
|
|
6524
|
+
"url": url_for("serve_media", filename=rel),
|
|
6525
|
+
"kind": kind
|
|
6526
|
+
})
|
|
6527
|
+
|
|
6528
|
+
items.sort(key=lambda x: x["path"])
|
|
6529
|
+
return jsonify({"items": items}), 200
|
|
6530
|
+
|
|
6531
|
+
# # Example usage in your existing routes
|
|
6532
|
+
# @smx.app.route("/admin/generate_image", methods=["POST"])
|
|
6533
|
+
# def generate_image_route():
|
|
6534
|
+
# prompt = request.json.get("prompt", "").strip()
|
|
6535
|
+
# kind = request.json.get("kind", "image")
|
|
6536
|
+
# count = int(request.json.get("count", 1))
|
|
6537
|
+
# out_dir = os.path.join(MEDIA_IMAGES_GENERATED_ICONS if kind == "icon" else MEDIA_IMAGES_GENERATED)
|
|
6538
|
+
|
|
6539
|
+
# if not prompt:
|
|
6540
|
+
# return jsonify({"error": "Missing prompt"}), 400
|
|
6541
|
+
|
|
6542
|
+
# vision_profile = smx.get_image_generator_profile()
|
|
6543
|
+
|
|
6544
|
+
# # Call the agent's generate_image function
|
|
6545
|
+
# try:
|
|
6546
|
+
# result = image_generator_agent(prompt, vision_profile, out_dir, count)
|
|
6547
|
+
# return jsonify({"items": result}), 200
|
|
6548
|
+
# except Exception as e:
|
|
6549
|
+
# return jsonify({"error": f"Image generation failed: {str(e)}"}), 500
|
|
6550
|
+
|
|
6551
|
+
|
|
5370
6552
|
# --- UPLOAD MEDIA --------------------------------------
|
|
5371
6553
|
@smx.app.route("/admin/upload_media", methods=["POST"])
|
|
5372
|
-
def upload_media():
|
|
5373
|
-
# Retrieve uploaded media files (images, videos, etc.).
|
|
6554
|
+
def upload_media():
|
|
5374
6555
|
uploaded_files = request.files.getlist("media_files")
|
|
5375
6556
|
file_paths = []
|
|
6557
|
+
|
|
5376
6558
|
for file in uploaded_files:
|
|
5377
|
-
if file.filename:
|
|
5378
|
-
|
|
6559
|
+
if not file or not file.filename:
|
|
6560
|
+
continue
|
|
6561
|
+
|
|
6562
|
+
fn = file.filename
|
|
6563
|
+
ext = os.path.splitext(fn.lower())[1]
|
|
6564
|
+
img_ext = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
|
6565
|
+
vid_ext = {".mp4", ".webm", ".mov", ".m4v"}
|
|
6566
|
+
|
|
6567
|
+
if ext in img_ext:
|
|
6568
|
+
filepath = os.path.join(MEDIA_IMAGES_UPLOADED, fn)
|
|
6569
|
+
file.save(filepath)
|
|
6570
|
+
rel = os.path.relpath(filepath, MEDIA_FOLDER).replace("\\", "/")
|
|
6571
|
+
file_paths.append(f"/uploads/media/{rel}")
|
|
6572
|
+
elif ext in vid_ext:
|
|
6573
|
+
filepath = os.path.join(MEDIA_VIDEOS_UPLOADED, fn)
|
|
5379
6574
|
file.save(filepath)
|
|
5380
|
-
|
|
5381
|
-
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
|
+
|
|
5382
6582
|
return jsonify({"file_paths": file_paths})
|
|
5383
6583
|
|
|
6584
|
+
|
|
5384
6585
|
# Serve the raw media files
|
|
5385
6586
|
@smx.app.route('/uploads/media/<path:filename>')
|
|
5386
6587
|
def serve_media(filename):
|
|
5387
6588
|
media_dir = os.path.join(_CLIENT_DIR, 'uploads', 'media')
|
|
5388
6589
|
return send_from_directory(media_dir, filename)
|
|
5389
6590
|
|
|
6591
|
+
|
|
6592
|
+
@smx.app.route("/branding/<path:filename>")
|
|
6593
|
+
def serve_branding(filename):
|
|
6594
|
+
branding_dir = os.path.join(_CLIENT_DIR, "branding")
|
|
6595
|
+
return send_from_directory(branding_dir, filename)
|
|
6596
|
+
|
|
6597
|
+
|
|
5390
6598
|
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
5391
6599
|
# DASHBOARD
|
|
5392
6600
|
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
@@ -5398,104 +6606,7 @@ def setup_routes(smx):
|
|
|
5398
6606
|
|
|
5399
6607
|
max_rows = 5000
|
|
5400
6608
|
max_cols = 80
|
|
5401
|
-
|
|
5402
|
-
def _smx_repair_python_cell(py_code: str) -> str:
|
|
5403
|
-
|
|
5404
|
-
_CELL_REPAIR_RULES = """
|
|
5405
|
-
You are an experienced Python code reviewer
|
|
5406
|
-
Fix the Python cell to satisfy:
|
|
5407
|
-
- Single valid cell; imports at the top.
|
|
5408
|
-
- Do not import or invoke or use 'python-dotenv' or 'dotenv' because it's not needed.
|
|
5409
|
-
- No top-level statements between if/elif/else branches.
|
|
5410
|
-
- Regression must use either sklearn with train_test_split (then X_test exists) and R^2/MAE/RMSE,
|
|
5411
|
-
or statsmodels OLS. No accuracy_score in regression.
|
|
5412
|
-
- Keep all plotting + savefig + BytesIO + display inside the branch that created the figure.
|
|
5413
|
-
- Return ONLY the corrected cell.
|
|
5414
|
-
"""
|
|
5415
|
-
code = textwrap.dedent(py_code or "").strip()
|
|
5416
|
-
needs_fix = False
|
|
5417
|
-
if re.search(r"\baccuracy_score\b", code) and re.search(r"\bLinearRegression\b|\bOLS\b", code):
|
|
5418
|
-
needs_fix = True
|
|
5419
|
-
if re.search(r"\bX_test\b", code) and not re.search(r"\bX_test\s*=", code):
|
|
5420
|
-
needs_fix = True
|
|
5421
|
-
try:
|
|
5422
|
-
ast.parse(code)
|
|
5423
|
-
except SyntaxError:
|
|
5424
|
-
needs_fix = True
|
|
5425
|
-
if not needs_fix:
|
|
5426
|
-
return code
|
|
5427
|
-
|
|
5428
|
-
_prompt = f"```python\n{code}\n```"
|
|
5429
|
-
|
|
5430
|
-
prof = _prof.get_profile("classification") or _prof.get_profile("admin")
|
|
5431
|
-
if not prof:
|
|
5432
|
-
return code
|
|
5433
|
-
|
|
5434
|
-
prof["client"] = _prof.get_client(prof)
|
|
5435
|
-
_client = prof["client"]
|
|
5436
|
-
_model = prof["model"]
|
|
5437
|
-
_provider = prof["provider"].lower()
|
|
5438
|
-
|
|
5439
|
-
#1 Google
|
|
5440
|
-
if _provider == "google":
|
|
5441
|
-
from google.genai import types
|
|
5442
|
-
|
|
5443
|
-
fixed = _client.models.generate_content(
|
|
5444
|
-
model=_model,
|
|
5445
|
-
contents=_prompt,
|
|
5446
|
-
config=types.GenerateContentConfig(
|
|
5447
|
-
system_instruction=_CELL_REPAIR_RULES,
|
|
5448
|
-
temperature=0.8,
|
|
5449
|
-
max_output_tokens=1024,
|
|
5450
|
-
),
|
|
5451
|
-
)
|
|
5452
|
-
|
|
5453
|
-
#2 Openai
|
|
5454
|
-
elif _provider == "openai" and _model in GPT_MODELS_LATEST:
|
|
5455
|
-
|
|
5456
|
-
args = set_args(
|
|
5457
|
-
model=_model,
|
|
5458
|
-
instructions=_CELL_REPAIR_RULES,
|
|
5459
|
-
input=[{"role": "user", "content": _prompt}],
|
|
5460
|
-
previous_id=None,
|
|
5461
|
-
store=False,
|
|
5462
|
-
reasoning_effort="medium",
|
|
5463
|
-
verbosity="medium",
|
|
5464
|
-
)
|
|
5465
|
-
fixed = _out(_client.responses.create(**args))
|
|
5466
|
-
|
|
5467
|
-
# Anthropic
|
|
5468
|
-
elif _provider == "anthropic":
|
|
5469
|
-
|
|
5470
|
-
fixed = _client.messages.create(
|
|
5471
|
-
model=_model,
|
|
5472
|
-
max_tokens=1024,
|
|
5473
|
-
system=_CELL_REPAIR_RULES,
|
|
5474
|
-
messages=[{"role": "user", "content":_prompt}],
|
|
5475
|
-
stream=False,
|
|
5476
|
-
)
|
|
5477
|
-
|
|
5478
|
-
# OpenAI SDK
|
|
5479
|
-
else:
|
|
5480
|
-
fixed = _client.chat.completions.create(
|
|
5481
|
-
model=_model,
|
|
5482
|
-
messages=[
|
|
5483
|
-
{"role": "system", "content":_CELL_REPAIR_RULES},
|
|
5484
|
-
{"role": "user", "content":_prompt},
|
|
5485
|
-
],
|
|
5486
|
-
max_tokens=1024,
|
|
5487
|
-
)
|
|
5488
|
-
|
|
5489
|
-
fixed_txt = clean_llm_code(fixed)
|
|
5490
|
-
|
|
5491
|
-
try:
|
|
5492
|
-
# Only accept the repaired cell if it's valid Python
|
|
5493
|
-
ast.parse(fixed_txt)
|
|
5494
|
-
return fixed_txt
|
|
5495
|
-
except Exception:
|
|
5496
|
-
# If the repaired version is still broken, fall back to the original code
|
|
5497
|
-
return code
|
|
5498
|
-
|
|
6609
|
+
|
|
5499
6610
|
section = request.args.get("section", "explore")
|
|
5500
6611
|
datasets = [f for f in os.listdir(DATA_FOLDER) if f.lower().endswith(".csv")]
|
|
5501
6612
|
selected_dataset = request.form.get("dataset") or request.args.get("dataset")
|
|
@@ -5532,6 +6643,8 @@ def setup_routes(smx):
|
|
|
5532
6643
|
eda_df = df
|
|
5533
6644
|
llm_usage = None
|
|
5534
6645
|
|
|
6646
|
+
TOKENS = {}
|
|
6647
|
+
|
|
5535
6648
|
if request.method == "POST" and "askai_question" in request.form:
|
|
5536
6649
|
askai_question = request.form["askai_question"].strip()
|
|
5537
6650
|
if df is not None:
|
|
@@ -5547,55 +6660,95 @@ def setup_routes(smx):
|
|
|
5547
6660
|
columns_summary = ", ".join(df.columns.tolist())
|
|
5548
6661
|
dataset_context = f"columns: {columns_summary}"
|
|
5549
6662
|
dataset_profile = f"modality: tabular; columns: {columns_summary}"
|
|
5550
|
-
|
|
5551
|
-
|
|
5552
|
-
|
|
5553
|
-
|
|
5554
|
-
|
|
5555
|
-
|
|
5556
|
-
|
|
5557
|
-
|
|
5558
|
-
#
|
|
5559
|
-
|
|
5560
|
-
|
|
5561
|
-
"df_columns": list(df.columns),
|
|
5562
|
-
}
|
|
5563
|
-
ai_code = ToolRunner(EARLY_SANITIZERS).run(ai_code, ctx) # dotenv first
|
|
6663
|
+
|
|
6664
|
+
# ai_code = smx.ai_generate_code(refined_question, tags, df)
|
|
6665
|
+
# llm_usage = smx.get_last_llm_usage()
|
|
6666
|
+
# ai_code = auto_inject_template(ai_code, tags, df)
|
|
6667
|
+
|
|
6668
|
+
# # --- 1) Strip dotenv ASAP (kill imports, %magics, !pip) ---
|
|
6669
|
+
# ctx = {
|
|
6670
|
+
# "question": refined_question,
|
|
6671
|
+
# "df_columns": list(df.columns),
|
|
6672
|
+
# }
|
|
6673
|
+
# ai_code = ToolRunner(EARLY_SANITIZERS).run(ai_code, ctx) # dotenv first
|
|
5564
6674
|
|
|
5565
|
-
# --- 2) Domain/Plotting patches ---
|
|
5566
|
-
ai_code = fix_scatter_and_summary(ai_code)
|
|
5567
|
-
ai_code = fix_importance_groupby(ai_code)
|
|
5568
|
-
ai_code = inject_auto_preprocessing(ai_code)
|
|
5569
|
-
ai_code = patch_plot_code(ai_code, df, refined_question)
|
|
5570
|
-
ai_code = ensure_matplotlib_title(ai_code)
|
|
5571
|
-
ai_code = patch_pie_chart(ai_code, df, refined_question)
|
|
5572
|
-
ai_code = patch_pairplot(ai_code, df)
|
|
5573
|
-
ai_code = fix_seaborn_boxplot_nameerror(ai_code)
|
|
5574
|
-
ai_code = fix_seaborn_barplot_nameerror(ai_code)
|
|
5575
|
-
ai_code = get_plotting_imports(ai_code)
|
|
5576
|
-
ai_code = patch_prefix_seaborn_calls(ai_code)
|
|
5577
|
-
ai_code = patch_fix_sentinel_plot_calls(ai_code)
|
|
5578
|
-
ai_code = patch_ensure_seaborn_import(ai_code)
|
|
5579
|
-
ai_code = patch_rmse_calls(ai_code)
|
|
5580
|
-
ai_code = patch_fix_seaborn_palette_calls(ai_code)
|
|
5581
|
-
ai_code = patch_quiet_specific_warnings(ai_code)
|
|
5582
|
-
ai_code = clean_llm_code(ai_code)
|
|
5583
|
-
ai_code = ensure_image_output(ai_code)
|
|
5584
|
-
ai_code = ensure_accuracy_block(ai_code)
|
|
5585
|
-
ai_code = ensure_output(ai_code)
|
|
5586
|
-
ai_code = fix_plain_prints(ai_code)
|
|
5587
|
-
ai_code = fix_print_html(ai_code)
|
|
5588
|
-
ai_code = fix_to_datetime_errors(ai_code)
|
|
6675
|
+
# # --- 2) Domain/Plotting patches ---
|
|
6676
|
+
# ai_code = fix_scatter_and_summary(ai_code)
|
|
6677
|
+
# ai_code = fix_importance_groupby(ai_code)
|
|
6678
|
+
# ai_code = inject_auto_preprocessing(ai_code)
|
|
6679
|
+
# ai_code = patch_plot_code(ai_code, df, refined_question)
|
|
6680
|
+
# ai_code = ensure_matplotlib_title(ai_code)
|
|
6681
|
+
# ai_code = patch_pie_chart(ai_code, df, refined_question)
|
|
6682
|
+
# ai_code = patch_pairplot(ai_code, df)
|
|
6683
|
+
# ai_code = fix_seaborn_boxplot_nameerror(ai_code)
|
|
6684
|
+
# ai_code = fix_seaborn_barplot_nameerror(ai_code)
|
|
6685
|
+
# ai_code = get_plotting_imports(ai_code)
|
|
6686
|
+
# ai_code = patch_prefix_seaborn_calls(ai_code)
|
|
6687
|
+
# ai_code = patch_fix_sentinel_plot_calls(ai_code)
|
|
6688
|
+
# ai_code = patch_ensure_seaborn_import(ai_code)
|
|
6689
|
+
# ai_code = patch_rmse_calls(ai_code)
|
|
6690
|
+
# ai_code = patch_fix_seaborn_palette_calls(ai_code)
|
|
6691
|
+
# ai_code = patch_quiet_specific_warnings(ai_code)
|
|
6692
|
+
# ai_code = clean_llm_code(ai_code)
|
|
6693
|
+
# ai_code = ensure_image_output(ai_code)
|
|
6694
|
+
# ai_code = ensure_accuracy_block(ai_code)
|
|
6695
|
+
# ai_code = ensure_output(ai_code)
|
|
6696
|
+
# ai_code = fix_plain_prints(ai_code)
|
|
6697
|
+
# ai_code = fix_print_html(ai_code)
|
|
6698
|
+
# ai_code = fix_to_datetime_errors(ai_code)
|
|
5589
6699
|
|
|
5590
|
-
# --- 3-4) Global syntax/data fixers (must run AFTER patches, BEFORE final repair) ---
|
|
5591
|
-
ai_code = ToolRunner(SYNTAX_AND_REPAIR).run(ai_code, ctx)
|
|
5592
|
-
|
|
5593
|
-
# # --- 4) Final catch-all repair (run LAST) ---
|
|
5594
|
-
ai_code =
|
|
5595
|
-
ai_code = harden_ai_code(ai_code)
|
|
5596
|
-
ai_code = drop_bad_classification_metrics(ai_code, df)
|
|
5597
|
-
ai_code = patch_fix_sentinel_plot_calls(ai_code)
|
|
5598
|
-
|
|
6700
|
+
# # --- 3-4) Global syntax/data fixers (must run AFTER patches, BEFORE final repair) ---
|
|
6701
|
+
# ai_code = ToolRunner(SYNTAX_AND_REPAIR).run(ai_code, ctx)
|
|
6702
|
+
|
|
6703
|
+
# # # --- 4) Final catch-all repair (run LAST) ---
|
|
6704
|
+
# ai_code = smx.repair_python_cell(ai_code)
|
|
6705
|
+
# ai_code = harden_ai_code(ai_code)
|
|
6706
|
+
# ai_code = drop_bad_classification_metrics(ai_code, df)
|
|
6707
|
+
# ai_code = patch_fix_sentinel_plot_calls(ai_code)
|
|
6708
|
+
|
|
6709
|
+
from syntaxmatrix.agentic import agents_orchestrer
|
|
6710
|
+
orch = agents_orchestrer.OrchestrateMLSystem(askai_question, cleaned_path)
|
|
6711
|
+
result = orch.operator_agent()
|
|
6712
|
+
|
|
6713
|
+
refined_question = result["specs_cot"]
|
|
6714
|
+
|
|
6715
|
+
compatibility = context_compatibility(askai_question, dataset_context)
|
|
6716
|
+
if compatibility.lower() == "incompatible" or compatibility.lower() == "mismatch":
|
|
6717
|
+
return ("""
|
|
6718
|
+
<div style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center;">
|
|
6719
|
+
<h1 style="margin: 0 0 10px 0;">Oops: Context mismatch</h1>
|
|
6720
|
+
<p style="margin: 0;">Please, upload the proper dataset for solution to your query.</p>
|
|
6721
|
+
<br>
|
|
6722
|
+
<a class='button' href='/dashboard' style='text-decoration:none;'>Return</a>
|
|
6723
|
+
</div>
|
|
6724
|
+
""")
|
|
6725
|
+
else:
|
|
6726
|
+
tags = classify_ml_job_agent(refined_question, dataset_profile)
|
|
6727
|
+
|
|
6728
|
+
TOKENS["Refiner"] = [
|
|
6729
|
+
result['token_usage'].get('Refiner')['usage'].get('provider'),
|
|
6730
|
+
result['token_usage'].get('Refiner')['usage'].get('model'),
|
|
6731
|
+
result['token_usage'].get('Refiner')['usage'].get('input_tokens'),
|
|
6732
|
+
result['token_usage'].get('Refiner')['usage'].get('output_tokens'),
|
|
6733
|
+
result['token_usage'].get('Refiner')['usage'].get('total_tokens'),
|
|
6734
|
+
]
|
|
6735
|
+
TOKENS["Coder"] = [
|
|
6736
|
+
result['token_usage'].get('Coder')['usage'].get('provider'),
|
|
6737
|
+
result['token_usage'].get('Coder')['usage'].get('model'),
|
|
6738
|
+
result['token_usage'].get('Coder')['usage'].get('input_tokens'),
|
|
6739
|
+
result['token_usage'].get('Coder')['usage'].get('output_tokens'),
|
|
6740
|
+
result['token_usage'].get('Coder')['usage'].get('total_tokens'),
|
|
6741
|
+
]
|
|
6742
|
+
|
|
6743
|
+
ai_code = result.get("python_code", "")
|
|
6744
|
+
# ai_code = patch_quiet_specific_warnings(ai_code)
|
|
6745
|
+
# ai_code = fix_print_html(ai_code)
|
|
6746
|
+
# ai_code = fix_plain_prints(ai_code)
|
|
6747
|
+
# ai_code = harden_ai_code(ai_code)
|
|
6748
|
+
# ai_code = ensure_image_output(ai_code)
|
|
6749
|
+
# ai_code = ensure_accuracy_block(ai_code)
|
|
6750
|
+
# ai_code = ensure_output(ai_code)
|
|
6751
|
+
|
|
5599
6752
|
# Always make sure 'df' is in the kernel before running user code
|
|
5600
6753
|
df_init_code = (
|
|
5601
6754
|
f"import pandas as pd\n"
|
|
@@ -5649,7 +6802,6 @@ def setup_routes(smx):
|
|
|
5649
6802
|
"<meta charset='utf-8'>"
|
|
5650
6803
|
"<title>Result</title>"
|
|
5651
6804
|
"<style>"
|
|
5652
|
-
" body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 42px; padding:24 32; }"
|
|
5653
6805
|
" img { max-width: 100%; height: auto; }"
|
|
5654
6806
|
" table { border-collapse: collapse; margin: 16px 0; }"
|
|
5655
6807
|
" th, td { border: 1px solid #ddd; padding: 6px 10px; }"
|
|
@@ -5832,14 +6984,34 @@ def setup_routes(smx):
|
|
|
5832
6984
|
|
|
5833
6985
|
# 3) Data Preview
|
|
5834
6986
|
preview_cols = df.columns
|
|
5835
|
-
|
|
6987
|
+
|
|
6988
|
+
head_df = _coerce_intlike_for_display(df[preview_cols].head(8))
|
|
5836
6989
|
data_cells.append({
|
|
5837
|
-
"title": "
|
|
5838
|
-
"output": Markup(datatable_box(
|
|
6990
|
+
"title": "Dataset Head",
|
|
6991
|
+
"output": Markup(datatable_box(head_df)),
|
|
5839
6992
|
"code": f"df[{list(preview_cols)}].head(8)",
|
|
5840
6993
|
"span": "eda-col-6"
|
|
5841
6994
|
})
|
|
5842
6995
|
|
|
6996
|
+
# Calculate the start index for the middle 8 rows
|
|
6997
|
+
n_rows = len(df)
|
|
6998
|
+
start_index = max(0, floor(n_rows / 2) - 4)
|
|
6999
|
+
middle_df = df.iloc[start_index : start_index + 8]
|
|
7000
|
+
data_cells.append({
|
|
7001
|
+
"title": "Dataset Middle (8 Rows)",
|
|
7002
|
+
"output": Markup(datatable_box(middle_df[list(preview_cols)])),
|
|
7003
|
+
"code": f"n = len(df)\nstart_index = max(0, floor(n / 2) - 4)\ndf.iloc[start_index : start_index + 8][{list(preview_cols)}]",
|
|
7004
|
+
"span": "eda-col-6"
|
|
7005
|
+
})
|
|
7006
|
+
|
|
7007
|
+
tail_df = _coerce_intlike_for_display(df[preview_cols].tail(8))
|
|
7008
|
+
data_cells.append({
|
|
7009
|
+
"title": "Dataset Tail",
|
|
7010
|
+
"output": Markup(datatable_box(tail_df)),
|
|
7011
|
+
"code": f"df[{list(preview_cols)}].tail(8)",
|
|
7012
|
+
"span": "eda-col-6"
|
|
7013
|
+
})
|
|
7014
|
+
|
|
5843
7015
|
# 4) Summary Statistics
|
|
5844
7016
|
summary_cols = df.columns
|
|
5845
7017
|
summary_df = _coerce_intlike_for_display(df[summary_cols].describe())
|
|
@@ -6193,7 +7365,7 @@ def setup_routes(smx):
|
|
|
6193
7365
|
"})\\n"
|
|
6194
7366
|
"missing_df[missing_df['Missing Values'] > 0]"
|
|
6195
7367
|
),
|
|
6196
|
-
"span":"eda-col-
|
|
7368
|
+
"span":"eda-col-3"
|
|
6197
7369
|
})
|
|
6198
7370
|
|
|
6199
7371
|
# 9) Missingness (Top 20) – Plotly bar chart
|
|
@@ -6432,7 +7604,7 @@ def setup_routes(smx):
|
|
|
6432
7604
|
"vc = s.value_counts(dropna=False)\n"
|
|
6433
7605
|
"top_k = 8 # Top-8 + Other (+ Missing)\n"
|
|
6434
7606
|
),
|
|
6435
|
-
"span": "eda-col-
|
|
7607
|
+
"span": "eda-col-5"
|
|
6436
7608
|
})
|
|
6437
7609
|
else:
|
|
6438
7610
|
data_cells.append({
|
|
@@ -6453,7 +7625,8 @@ def setup_routes(smx):
|
|
|
6453
7625
|
cell["highlighted_code"] = Markup(_pygmentize(cell["code"]))
|
|
6454
7626
|
|
|
6455
7627
|
highlighted_ai_code = _pygmentize(ai_code)
|
|
6456
|
-
|
|
7628
|
+
smxAI = "smx-Orion"
|
|
7629
|
+
|
|
6457
7630
|
return render_template(
|
|
6458
7631
|
"dashboard.html",
|
|
6459
7632
|
section=section,
|
|
@@ -6465,9 +7638,10 @@ def setup_routes(smx):
|
|
|
6465
7638
|
askai_question=smx.sanitize_rough_to_markdown_task(askai_question),
|
|
6466
7639
|
refined_question=refined_question,
|
|
6467
7640
|
tasks=tags,
|
|
7641
|
+
smxAI=smxAI,
|
|
6468
7642
|
data_cells=data_cells,
|
|
6469
7643
|
session_id=session_id,
|
|
6470
|
-
|
|
7644
|
+
TOKENS=TOKENS
|
|
6471
7645
|
)
|
|
6472
7646
|
|
|
6473
7647
|
|
|
@@ -6528,6 +7702,179 @@ def setup_routes(smx):
|
|
|
6528
7702
|
# go back to the dashboard; dashboard() will auto-select the next file
|
|
6529
7703
|
return redirect(url_for("dashboard"))
|
|
6530
7704
|
|
|
7705
|
+
# ── DATASET RESIZE (independent helper page) -------------------------
|
|
7706
|
+
|
|
7707
|
+
|
|
7708
|
+
@smx.app.route("/dataset/resize", methods=["GET", "POST"])
|
|
7709
|
+
def dataset_resize():
|
|
7710
|
+
"""
|
|
7711
|
+
User uploads any CSV and picks a target size (percentage of rows).
|
|
7712
|
+
We keep the last resized CSV in memory and expose a download link.
|
|
7713
|
+
"""
|
|
7714
|
+
# One id per browser session to index _last_resized_csv
|
|
7715
|
+
resize_id = session.get("dataset_resize_id")
|
|
7716
|
+
if not resize_id:
|
|
7717
|
+
resize_id = str(uuid.uuid4())
|
|
7718
|
+
session["dataset_resize_id"] = resize_id
|
|
7719
|
+
|
|
7720
|
+
resize_info = None # stats we pass down to the template
|
|
7721
|
+
|
|
7722
|
+
if request.method == "POST":
|
|
7723
|
+
file = request.files.get("dataset_file")
|
|
7724
|
+
target_pct_raw = (request.form.get("target_pct") or "").strip()
|
|
7725
|
+
strat_col = (request.form.get("strat_col") or "").strip()
|
|
7726
|
+
|
|
7727
|
+
error_msg = None
|
|
7728
|
+
df = None
|
|
7729
|
+
|
|
7730
|
+
# --- Basic validation ---
|
|
7731
|
+
if not file or file.filename == "":
|
|
7732
|
+
error_msg = "Please choose a CSV file."
|
|
7733
|
+
elif not file.filename.lower().endswith(".csv"):
|
|
7734
|
+
error_msg = "Only CSV files are supported."
|
|
7735
|
+
|
|
7736
|
+
# --- Read CSV into a DataFrame ---
|
|
7737
|
+
if not error_msg:
|
|
7738
|
+
try:
|
|
7739
|
+
df = pd.read_csv(file)
|
|
7740
|
+
except Exception as e:
|
|
7741
|
+
error_msg = f"Could not read CSV: {e}"
|
|
7742
|
+
|
|
7743
|
+
# --- Parse target percentage ---
|
|
7744
|
+
pct = None
|
|
7745
|
+
if not error_msg:
|
|
7746
|
+
try:
|
|
7747
|
+
pct = float(target_pct_raw)
|
|
7748
|
+
except Exception:
|
|
7749
|
+
error_msg = "Target size must be a number between 1 and 100."
|
|
7750
|
+
|
|
7751
|
+
if not error_msg and (pct <= 0 or pct > 100):
|
|
7752
|
+
error_msg = "Target size must be between 1 and 100."
|
|
7753
|
+
|
|
7754
|
+
if error_msg:
|
|
7755
|
+
flash(error_msg, "error")
|
|
7756
|
+
else:
|
|
7757
|
+
frac = pct / 100.0
|
|
7758
|
+
n_orig = len(df)
|
|
7759
|
+
n_target = max(1, int(round(n_orig * frac)))
|
|
7760
|
+
|
|
7761
|
+
df_resized = None
|
|
7762
|
+
used_strat = False
|
|
7763
|
+
|
|
7764
|
+
# --- Advanced: stratified sampling by a column (behind 'Show advanced options') ---
|
|
7765
|
+
if strat_col and strat_col in df.columns and n_orig > 0:
|
|
7766
|
+
used_strat = True
|
|
7767
|
+
groups = df.groupby(strat_col, sort=False)
|
|
7768
|
+
|
|
7769
|
+
# First pass: proportional allocation with rounding and minimum 1 per non-empty group
|
|
7770
|
+
allocations = {}
|
|
7771
|
+
total_alloc = 0
|
|
7772
|
+
for key, group in groups:
|
|
7773
|
+
size = len(group)
|
|
7774
|
+
if size <= 0:
|
|
7775
|
+
allocations[key] = 0
|
|
7776
|
+
continue
|
|
7777
|
+
alloc = int(round(size * frac))
|
|
7778
|
+
if alloc == 0 and size > 0:
|
|
7779
|
+
alloc = 1
|
|
7780
|
+
if alloc > size:
|
|
7781
|
+
alloc = size
|
|
7782
|
+
allocations[key] = alloc
|
|
7783
|
+
total_alloc += alloc
|
|
7784
|
+
|
|
7785
|
+
keys = list(allocations.keys())
|
|
7786
|
+
|
|
7787
|
+
# Adjust downwards if we overshot
|
|
7788
|
+
if total_alloc > n_target:
|
|
7789
|
+
idx = 0
|
|
7790
|
+
while total_alloc > n_target and any(v > 1 for v in allocations.values()):
|
|
7791
|
+
k = keys[idx % len(keys)]
|
|
7792
|
+
if allocations[k] > 1:
|
|
7793
|
+
allocations[k] -= 1
|
|
7794
|
+
total_alloc -= 1
|
|
7795
|
+
idx += 1
|
|
7796
|
+
|
|
7797
|
+
# Adjust upwards if we undershot and we still have room in groups
|
|
7798
|
+
if total_alloc < n_target and keys:
|
|
7799
|
+
idx = 0
|
|
7800
|
+
while total_alloc < n_target:
|
|
7801
|
+
k = keys[idx % len(keys)]
|
|
7802
|
+
group_size = len(groups.get_group(k))
|
|
7803
|
+
if allocations[k] < group_size:
|
|
7804
|
+
allocations[k] += 1
|
|
7805
|
+
total_alloc += 1
|
|
7806
|
+
idx += 1
|
|
7807
|
+
if idx > len(keys) * 3:
|
|
7808
|
+
break
|
|
7809
|
+
|
|
7810
|
+
sampled_parts = []
|
|
7811
|
+
for key, group in groups:
|
|
7812
|
+
n_g = allocations.get(key, 0)
|
|
7813
|
+
if n_g > 0:
|
|
7814
|
+
sampled_parts.append(group.sample(n=n_g, random_state=0))
|
|
7815
|
+
|
|
7816
|
+
if sampled_parts:
|
|
7817
|
+
df_resized = (
|
|
7818
|
+
pd.concat(sampled_parts, axis=0)
|
|
7819
|
+
.sample(frac=1.0, random_state=0)
|
|
7820
|
+
.reset_index(drop=True)
|
|
7821
|
+
)
|
|
7822
|
+
|
|
7823
|
+
# --- Default: simple random sample over all rows ---
|
|
7824
|
+
if df_resized is None:
|
|
7825
|
+
if n_target >= n_orig:
|
|
7826
|
+
df_resized = df.copy()
|
|
7827
|
+
else:
|
|
7828
|
+
df_resized = df.sample(n=n_target, random_state=0).reset_index(drop=True)
|
|
7829
|
+
if strat_col and strat_col not in df.columns:
|
|
7830
|
+
flash(
|
|
7831
|
+
f"Column '{strat_col}' not found. Used simple random sampling instead.",
|
|
7832
|
+
"warning",
|
|
7833
|
+
)
|
|
7834
|
+
|
|
7835
|
+
# --- Serialise to CSV in memory and stash in _last_resized_csv ---
|
|
7836
|
+
buf = _std_io.BytesIO()
|
|
7837
|
+
df_resized.to_csv(buf, index=False)
|
|
7838
|
+
buf.seek(0)
|
|
7839
|
+
_last_resized_csv[resize_id] = buf.getvalue()
|
|
7840
|
+
|
|
7841
|
+
resize_info = {
|
|
7842
|
+
"rows_in": n_orig,
|
|
7843
|
+
"rows_out": len(df_resized),
|
|
7844
|
+
"pct": pct,
|
|
7845
|
+
"used_strat": used_strat,
|
|
7846
|
+
"strat_col": strat_col if used_strat else "",
|
|
7847
|
+
}
|
|
7848
|
+
flash("Dataset resized successfully. Use the download link below.", "success")
|
|
7849
|
+
|
|
7850
|
+
return render_template("dataset_resize.html", resize_info=resize_info)
|
|
7851
|
+
|
|
7852
|
+
@smx.app.route("/dataset/resize/download", methods=["GET"])
|
|
7853
|
+
def download_resized_dataset():
|
|
7854
|
+
"""Download the last resized dataset for this browser session as a CSV."""
|
|
7855
|
+
resize_id = session.get("dataset_resize_id")
|
|
7856
|
+
if not resize_id:
|
|
7857
|
+
return ("No resized dataset available.", 404)
|
|
7858
|
+
|
|
7859
|
+
data = _last_resized_csv.get(resize_id)
|
|
7860
|
+
if not data:
|
|
7861
|
+
return ("No resized dataset available.", 404)
|
|
7862
|
+
|
|
7863
|
+
buf = _std_io.BytesIO(data)
|
|
7864
|
+
buf.seek(0)
|
|
7865
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
|
|
7866
|
+
filename = f"resized_dataset_{stamp}.csv"
|
|
7867
|
+
|
|
7868
|
+
# Drop it from memory once downloaded
|
|
7869
|
+
_last_resized_csv.pop(resize_id, None)
|
|
7870
|
+
|
|
7871
|
+
return send_file(
|
|
7872
|
+
buf,
|
|
7873
|
+
mimetype="text/csv; charset=utf-8",
|
|
7874
|
+
as_attachment=True,
|
|
7875
|
+
download_name=filename,
|
|
7876
|
+
)
|
|
7877
|
+
|
|
6531
7878
|
|
|
6532
7879
|
def _pdf_fallback_reportlab(full_html: str):
|
|
6533
7880
|
"""ReportLab fallback: extract text + base64 <img> and lay them out."""
|
|
@@ -6621,4 +7968,5 @@ def setup_routes(smx):
|
|
|
6621
7968
|
{footer}
|
|
6622
7969
|
</body>
|
|
6623
7970
|
</html>
|
|
6624
|
-
""", error_message=str(e)), 500
|
|
7971
|
+
""", error_message=str(e)), 500
|
|
7972
|
+
|