diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4881a44..fcfd64a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - name: Run ruff format check run: uv run ruff format --check main.py src/ tests/ scripts/ - name: Biome check - run: npx @biomejs/biome check src/presentation/static/ + run: npx -y @biomejs/biome@2.5.1 check src/presentation/static/ typecheck: runs-on: ubuntu-latest diff --git a/AskRag/test_rag_ask.py b/AskRag/test_rag_ask.py index 8ac94cd..e18e183 100755 --- a/AskRag/test_rag_ask.py +++ b/AskRag/test_rag_ask.py @@ -200,7 +200,7 @@ def _build_test_app(): """Build a minimal FastAPI app with only the search endpoint.""" from fastapi import FastAPI, HTTPException - app = FastAPI(title="SigmaHQ RAG — test") + app = FastAPI(title="SigmaForge — test") @app.post("/search") async def search(request: _SearchRequest) -> dict: diff --git a/README.md b/README.md index be20d5f..7507b60 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# SigmaHQ RAG +# SigmaForge A local RAG system for Sigma detection rules. diff --git a/main.py b/main.py index 9edf9d8..2cea3a8 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,4 @@ -"""SigmaHQ Rag - Application entry point.""" +"""SigmaForge - Application entry point.""" import asyncio import sys diff --git a/pyproject.toml b/pyproject.toml index 657a966..b2bd3fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "sigmahqrag" +name = "sigmaforge" version = "0.1.0" description = "Local RAG system for Sigma rules" requires-python = ">=3.12" diff --git a/src/__init__.py b/src/__init__.py index ab70a01..a65fdc7 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,3 +1,3 @@ -"""SigmaHQ RAG - Local RAG system for Sigma rules.""" +"""SigmaForge - Local RAG system for Sigma rules.""" __version__ = "0.1.0" diff --git a/src/api/routes/page_admin.py b/src/api/routes/page_admin.py index 2b2a658..a64985f 100644 --- a/src/api/routes/page_admin.py +++ b/src/api/routes/page_admin.py @@ -21,10 +21,12 @@ async def config_page(request: Request): """Serve the unified config dashboard page.""" cfg = get_config().to_dict() + # Escape " tag. + config_json = json.dumps(cfg).replace(" Path: LOG_FILES = { - "system": "sigmahqrag.log", + "system": "sigmaforge.log", "llamacpp": "llama.cpp.log", "qdrant": "qdrant.log", } diff --git a/src/application/system/duckdb.py b/src/application/system/duckdb.py index 812a735..ab2b423 100644 --- a/src/application/system/duckdb.py +++ b/src/application/system/duckdb.py @@ -26,7 +26,7 @@ class DuckDbStatus(NamedTuple): needs_clean: bool -# Project root: goes up from src/application/system/ to sigmahqrag/ +# Project root: goes up from src/application/system/ to SigmaForge/ _PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent # Tables that must exist per initdb.sql (CREATE TABLE IF NOT EXISTS order) diff --git a/src/config/constants.py b/src/config/constants.py index 8685f21..e17205c 100644 --- a/src/config/constants.py +++ b/src/config/constants.py @@ -1,6 +1,6 @@ """Application constants - single source of truth for cross-cutting values.""" -SCHEMA_VERSION = 20260619 +SCHEMA_VERSION = 20260704 SIGMA_SPEC_REPO = "https://github.com/SigmaHQ/sigma-specification" SIGMA_SPEC_REF = "main" diff --git a/src/infrastructure/database/initdb.sql b/src/infrastructure/database/initdb.sql index dbf4a03..23b37fd 100644 --- a/src/infrastructure/database/initdb.sql +++ b/src/infrastructure/database/initdb.sql @@ -1,4 +1,4 @@ --- SigmaHQ RAG - DuckDB Schema + Seed Data +-- SigmaForge - DuckDB Schema + Seed Data -- Single source of truth for database initialization. -- All CREATE TABLE are IF NOT EXISTS and INSERT are OR IGNORE for idempotency. diff --git a/src/infrastructure/vectorstore/auto_start.py b/src/infrastructure/vectorstore/auto_start.py index 026a217..945b2aa 100644 --- a/src/infrastructure/vectorstore/auto_start.py +++ b/src/infrastructure/vectorstore/auto_start.py @@ -91,7 +91,7 @@ async def start_qdrant( _started_binary_service = binary_service assert health_check is not None - for _ in range(10): + for _ in range(30): try: health = await health_check(timeout=2.0, port=config.qdrant_port) if health.get("status") == "active": @@ -102,7 +102,7 @@ async def start_qdrant( pass await asyncio.sleep(1) - raise ServiceStartError("Qdrant process started but health check timed out after 10s") + raise ServiceStartError("Qdrant process started but health check timed out after 30s") async def stop_qdrant() -> None: diff --git a/src/infrastructure/vectorstore/collections.py b/src/infrastructure/vectorstore/collections.py index 6ad1084..fc0fdc4 100644 --- a/src/infrastructure/vectorstore/collections.py +++ b/src/infrastructure/vectorstore/collections.py @@ -105,6 +105,7 @@ def _create_payload_indexes_sync(client, collection_name: str) -> None: collection_name=collection_name, field_name=field_name, field_schema=field_type, + wait=False, ) except Exception: logger.debug( diff --git a/src/main.py b/src/main.py index a6ee7d1..bef8164 100644 --- a/src/main.py +++ b/src/main.py @@ -137,7 +137,7 @@ def _setup_logging(level: str = "INFO", max_size: str = "10M", max_files: int = """Setup logging to file with rotation.""" from src.config.settings import LOGS_DIR - log_file = LOGS_DIR / "sigmahqrag.log" + log_file = LOGS_DIR / "sigmaforge.log" log_level = getattr(logging, level.upper(), logging.INFO) LOGS_DIR.mkdir(parents=True, exist_ok=True) @@ -207,6 +207,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: old_stderr = sys.stderr sys.stderr = StringIO() + def _status(message: str) -> None: + print(f" {message}", file=old_stderr, flush=True) + try: db = DatabaseService() db.initialize() @@ -242,6 +245,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: logger.info("Services validated.") if not setup_mode: + _status("Starting llama.cpp...") try: await start_llamacpp() logger.info("llama.cpp started.") @@ -249,6 +253,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: logger.warning("llama.cpp auto-start skipped: %s", e) qdrant_started = False + _status("Starting Qdrant...") try: await start_qdrant() qdrant_started = True @@ -257,6 +262,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: logger.warning("Qdrant auto-start skipped: %s", e) if qdrant_started: + _status("Initializing Qdrant collections...") await _init_qdrant_collections() logger.info("Qdrant collections initialized.") else: @@ -300,7 +306,7 @@ def _validate_services() -> None: def create_app() -> FastAPI: """Create and configure the FastAPI application.""" app = FastAPI( - title="SigmaHQ RAG", + title="SigmaForge", version="0.1.0", description="Local RAG system for Sigma rules", lifespan=lifespan, diff --git a/src/presentation/static/css/base/local.css b/src/presentation/static/css/base/local.css new file mode 100644 index 0000000..94be3b3 --- /dev/null +++ b/src/presentation/static/css/base/local.css @@ -0,0 +1,103 @@ +/* Local page utilities */ + +.file-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.upload-section { + background: var(--bg-card); + border: 2px dashed var(--color-secondary); + border-radius: 8px; + padding: 40px 20px; + text-align: center; + cursor: pointer; + transition: all 0.2s; + margin-bottom: 24px; +} + +.upload-section:hover, +.upload-section.dragover { + border-color: var(--color-primary); + background: var(--bg-status-row); +} + +.upload-section h3 { + margin: 0 0 8px 0; + color: var(--text-card-title); +} + +.upload-section p { + margin: 0; + color: var(--text-card-label); + font-size: 14px; +} + +#file-input { + display: none; +} + +.files-table { + background: var(--bg-card); + border-radius: 8px; + overflow: hidden; + box-shadow: 0 1px 3px var(--shadow-card); +} + +.files-table table { + width: 100%; + border-collapse: collapse; +} + +.files-table th { + background: var(--bg-sidebar); + color: var(--text-table-th); + padding: 12px 16px; + text-align: left; + font-weight: 600; + font-size: 14px; +} + +.files-table td { + padding: 12px 16px; + border-bottom: 1px solid var(--border-table-inner); + font-size: 14px; +} + +.files-table tr:hover { + background: var(--bg-status-row); +} + +.empty-state { + text-align: center; + padding: 60px 20px; + color: var(--text-card-label); +} + +.empty-state svg { + width: 64px; + height: 64px; + margin-bottom: 16px; + opacity: 0.5; +} + +#upload-progress { + display: none; + background: var(--bg-card); + border-radius: 8px; + padding: 16px; + margin-bottom: 24px; + box-shadow: 0 1px 3px var(--shadow-card); +} + +#status-message { + margin-top: 12px; + font-size: 14px; +} + +.upload-section:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 2px; +} diff --git a/src/presentation/static/css/base/theme.css b/src/presentation/static/css/base/theme.css index 1c31daa..6445508 100644 --- a/src/presentation/static/css/base/theme.css +++ b/src/presentation/static/css/base/theme.css @@ -85,60 +85,62 @@ --color-focus: #1e40af; - --chat-bg: #ffffff; - --chat-sidebar-bg: #f9fafb; - --chat-sidebar-hover: #f3f4f6; - --chat-sidebar-active: #e0f2fe; - --chat-sidebar-select-bg: #f9fafb; - --chat-card-bg: #f9fafb; - --chat-border: #e5e7eb; - --chat-border-light: #f3f4f6; - --chat-text: #111827; - --chat-text-secondary: #6b7280; - --chat-text-muted: #9ca3af; - --chat-text-dim: #d1d5db; - --chat-text-subtle: #6b7280; - --chat-label: #9ca3af; - --chat-primary: #2563eb; - --chat-primary-hover: #1d4ed8; - --chat-message-user-bg: #2563eb; + /* Chat tokens — aliased to the base palette (single source of truth) */ + --chat-bg: var(--bg-card); + --chat-sidebar-bg: var(--bg-sidebar); + --chat-sidebar-hover: var(--bg-sidebar-hover); + --chat-sidebar-active: var(--bg-sidebar-active); + --chat-sidebar-select-bg: var(--bg-sidebar); + --chat-card-bg: var(--bg-body); + --chat-border: var(--border-card); + --chat-border-light: var(--border-table-inner); + --chat-text: var(--text-body); + --chat-text-secondary: var(--text-card-label); + --chat-text-muted: var(--text-muted); + --chat-text-dim: var(--text-muted); + --chat-text-subtle: var(--text-card-label); + --chat-label: var(--text-card-label); + --chat-primary: var(--color-primary); + --chat-primary-hover: var(--color-primary-hover); + --chat-message-user-bg: var(--color-primary); --chat-message-user-text: #ffffff; - --chat-message-assistant-bg: #f9fafb; - --chat-message-assistant-text: #111827; - --chat-message-assistant-border: #e5e7eb; - --chat-message-error-bg: #fef2f2; - --chat-message-error-border: #fecaca; - --chat-message-error-text: #dc2626; - --chat-input-bg: #ffffff; - --chat-input-border: #e5e7eb; - --chat-input-text: #111827; - --chat-code-bg: #f3f4f6; - --chat-code-color: #dc2626; - --chat-pre-bg: #f9fafb; - --chat-link-color: #2563eb; - --chat-thinking-bg: #f9fafb; - --chat-thinking-border: #e5e7eb; - --chat-typing-bg: #f9fafb; - --chat-typing-dot: #d1d5db; - --chat-welcome-text: #9ca3af; - --chat-btn-bg: #f3f4f6; - --chat-btn-text: #374151; - --chat-btn-border: #e5e7eb; - --chat-text-code: #111827; - --chat-code-inline: #dc2626; - --chat-code-bg-deep: #f3f4f6; - --chat-blockquote-border: #e5e7eb; - --chat-header-border: #e5e7eb; - --chat-text-bright: #111827; + --chat-message-assistant-bg: var(--bg-body); + --chat-message-assistant-text: var(--text-body); + --chat-message-assistant-border: var(--border-card); + --chat-message-error-bg: var(--badge-bg-error); + --chat-message-error-border: var(--border-card); + --chat-message-error-text: var(--badge-text-error); + --chat-input-bg: var(--bg-input); + --chat-input-border: var(--border-input); + --chat-input-text: var(--text-body); + --chat-code-bg: var(--bg-body); + --chat-code-color: var(--badge-text-error); + --chat-pre-bg: var(--bg-card); + --chat-link-color: var(--color-primary); + --chat-thinking-bg: var(--bg-body); + --chat-thinking-border: var(--border-card); + --chat-typing-bg: var(--bg-body); + --chat-typing-dot: var(--text-muted); + --chat-welcome-text: var(--text-muted); + --chat-btn-bg: var(--bg-body); + --chat-btn-text: var(--text-body); + --chat-btn-border: var(--border-card); + --chat-text-code: var(--text-body); + --chat-code-inline: var(--badge-text-error); + --chat-code-bg-deep: var(--bg-body); + --chat-blockquote-border: var(--border-card); + --chat-header-border: var(--border-card); + --chat-text-bright: var(--text-body); - --log-level-error: #dc3545; - --log-level-warning: #e67e22; - --log-level-info: #2980b9; - --log-level-debug: #6b7280; - --log-text: #1a1a1a; - --log-text-muted: #888; - --log-output-bg: #ffffff; - --log-stat-text: #6b7280; + /* Log tokens — aliased to the base palette */ + --log-level-error: var(--badge-text-error); + --log-level-warning: var(--badge-text-warning); + --log-level-info: var(--badge-text-info); + --log-level-debug: var(--text-muted); + --log-text: var(--text-body); + --log-text-muted: var(--text-muted); + --log-output-bg: var(--bg-input); + --log-stat-text: var(--text-card-label); } .log-level-error { @@ -210,61 +212,6 @@ --badge-text-warning: #f0b27a; --badge-bg-info: #1a2a3a; --badge-text-info: #85c1e9; - - --chat-bg: #111827; - --chat-sidebar-bg: #0f172a; - --chat-sidebar-hover: #1e293b; - --chat-sidebar-active: #1e3a5f; - --chat-sidebar-select-bg: #1e293b; - --chat-card-bg: #1e293b; - --chat-border: #374151; - --chat-border-light: #1f2937; - --chat-text: #f3f4f6; - --chat-text-secondary: #9ca3af; - --chat-text-muted: #6b7280; - --chat-text-dim: #4b5563; - --chat-text-subtle: #d1d5db; - --chat-label: #6b7280; - --chat-primary: #3b82f6; - --chat-primary-hover: #60a5fa; - --chat-message-user-bg: #2563eb; - --chat-message-user-text: #ffffff; - --chat-message-assistant-bg: #1e293b; - --chat-message-assistant-text: #f3f4f6; - --chat-message-assistant-border: #374151; - --chat-message-error-bg: #451a1a; - --chat-message-error-border: #7f1d1d; - --chat-message-error-text: #fca5a5; - --chat-input-bg: #1e293b; - --chat-input-border: #374151; - --chat-input-text: #f3f4f6; - --chat-code-bg: #0f172a; - --chat-code-color: #f87171; - --chat-pre-bg: #0f172a; - --chat-link-color: #60a5fa; - --chat-thinking-bg: #0f172a; - --chat-thinking-border: #374151; - --chat-typing-bg: #1e293b; - --chat-typing-dot: #4b5563; - --chat-welcome-text: #6b7280; - --chat-btn-bg: #374151; - --chat-btn-text: #f3f4f6; - --chat-btn-border: #4b5563; - --chat-text-code: #f3f4f6; - --chat-code-inline: #f87171; - --chat-code-bg-deep: #0f172a; - --chat-blockquote-border: #4b5563; - --chat-header-border: #374151; - --chat-text-bright: #ffffff; - - --log-level-error: #f87171; - --log-level-warning: #facc15; - --log-level-info: #93c5fd; - --log-level-debug: #6b7280; - --log-text: #d4d4d4; - --log-text-muted: #666; - --log-output-bg: #1e1e1e; - --log-stat-text: #6b7280; } /* Smooth theme transitions */ diff --git a/src/presentation/static/css/base/utilities.css b/src/presentation/static/css/base/utilities.css new file mode 100644 index 0000000..bfbaef9 --- /dev/null +++ b/src/presentation/static/css/base/utilities.css @@ -0,0 +1,41 @@ +/* Shared utility classes (spacing, width, layout, display) — loaded globally */ + +.mt-2 { + margin-top: var(--space-2); +} +.mt-3 { + margin-top: var(--space-3); +} +.mt-4 { + margin-top: var(--space-4); +} +.mb-3 { + margin-bottom: var(--space-3); +} +.mb-4 { + margin-bottom: var(--space-4); +} +.w-70 { + width: 70px; +} +.w-80 { + width: 80px; +} +.min-w-200 { + min-width: 200px; +} +.flex-row { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; +} +.d-none { + display: none; +} +.card-flex-2 { + flex: 2; +} +.card-flex-1 { + flex: 1; +} diff --git a/src/presentation/static/css/components/forms.css b/src/presentation/static/css/components/forms.css index 24cf5cc..4b50c63 100644 --- a/src/presentation/static/css/components/forms.css +++ b/src/presentation/static/css/components/forms.css @@ -105,3 +105,54 @@ select.form-control-sm, select.input-sm { width: 80px; } + +/* Shared toggle switch (header + config) */ +.toggle { + position: relative; + display: inline-flex; + align-items: center; + gap: var(--space-2); + cursor: pointer; + user-select: none; + vertical-align: middle; + line-height: 0; +} +.toggle input { + position: absolute; + opacity: 0; + width: 1px; + height: 1px; + pointer-events: none; +} +.toggle-track { + position: relative; + display: inline-block; + width: 42px; + height: 22px; + background: var(--border-input); + border-radius: 11px; + transition: background 0.2s ease; + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2); + flex-shrink: 0; +} +.toggle-track::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + background: #ffffff; + border-radius: 50%; + transition: transform 0.2s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); +} +.toggle input:checked + .toggle-track { + background: var(--color-primary); +} +.toggle input:checked + .toggle-track::after { + transform: translateX(20px); +} +.toggle input:focus-visible + .toggle-track { + box-shadow: 0 0 0 2px var(--color-focus); +} diff --git a/src/presentation/static/css/config.css b/src/presentation/static/css/config.css index 9c4ce64..52abf03 100644 --- a/src/presentation/static/css/config.css +++ b/src/presentation/static/css/config.css @@ -20,45 +20,6 @@ min-width: auto; } -/* Toggle slider (logging clean-at-startup) */ -.theme-slider { - position: relative; - display: inline-block; - width: 42px; - height: 22px; - background: #94a3b8; - border-radius: 11px; - transition: background 0.3s ease; - box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2); - vertical-align: middle; - cursor: pointer; -} - -.theme-slider::after { - content: ""; - position: absolute; - top: 2px; - left: 2px; - width: 18px; - height: 18px; - background: #ffffff; - border-radius: 50%; - transition: transform 0.3s cubic-bezier(0.68, -0.55, 0.27, 1.55); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); -} - -.theme-toggle input:checked ~ .theme-slider { - background: var(--color-accent); -} - -.theme-toggle input:checked ~ .theme-slider::after { - transform: translateX(20px); -} - -.theme-toggle input:focus ~ .theme-slider { - box-shadow: 0 0 0 2px var(--color-focus); -} - .theme-text-label { font-size: var(--text-sm); color: var(--text-body); @@ -376,46 +337,6 @@ color: var(--text-card-body); } -/* ── Utility spacing classes (Phase 3.4) ── */ -.mt-2 { - margin-top: var(--space-2); -} -.mt-3 { - margin-top: var(--space-3); -} -.mt-4 { - margin-top: var(--space-4); -} -.mb-3 { - margin-bottom: var(--space-3); -} -.mb-4 { - margin-bottom: var(--space-4); -} -.w-70 { - width: 70px; -} -.w-80 { - width: 80px; -} -.min-w-200 { - min-width: 200px; -} -.flex-row { - display: flex; - flex-direction: row; - align-items: center; - gap: 8px; -} -.d-none { - display: none; -} -.card-flex-2 { - flex: 2; -} -.card-flex-1 { - flex: 1; -} .card-full { grid-column: 1 / -1; } diff --git a/src/presentation/static/css/layout/header-nav.css b/src/presentation/static/css/layout/header-nav.css index 427bb21..f9968bd 100644 --- a/src/presentation/static/css/layout/header-nav.css +++ b/src/presentation/static/css/layout/header-nav.css @@ -97,78 +97,6 @@ nav a:focus { opacity: 0.85; } -.theme-toggle { - cursor: pointer; - user-select: none; - line-height: 0; +.header-right .toggle { margin-left: auto; - display: flex; - align-items: center; -} - -.theme-toggle input { - position: absolute; - opacity: 0; - width: 0; - height: 0; - pointer-events: none; -} - -.theme-toggle-track { - position: relative; - display: inline-block; - width: 52px; - height: 26px; - background: #555; - border-radius: 13px; - transition: background 0.3s ease; - box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.35); -} - -[data-theme="dark"] .theme-toggle-track { - background: #2c3e50; -} - -.theme-toggle-icons { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 6px; - z-index: 0; -} - -.theme-icon { - font-size: 12px; - line-height: 1; -} - -.sun-icon { - color: #f1c40f; -} - -.moon-icon { - color: #bdc3c7; -} - -[data-theme="dark"] .moon-icon { - color: #9b59b6; -} - -.theme-toggle-knob { - position: absolute; - top: 3px; - left: 3px; - width: 20px; - height: 20px; - background: #fff; - border-radius: 50%; - z-index: 1; - transition: transform 0.3s cubic-bezier(0.68, -0.55, 0.27, 1.55); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); -} - -.theme-toggle input:checked + .theme-toggle-track .theme-toggle-knob { - transform: translateX(26px); } diff --git a/src/presentation/static/css/layout/sidebar.css b/src/presentation/static/css/layout/sidebar.css index 7852618..4894902 100644 --- a/src/presentation/static/css/layout/sidebar.css +++ b/src/presentation/static/css/layout/sidebar.css @@ -80,3 +80,32 @@ pointer-events: none; opacity: 0.6; } + +/* Collapsed sidebar state */ +.app-sidebar.collapsed { + width: 60px; +} + +.app-sidebar.collapsed .sidebar-nav a { + padding: var(--space-2) 0; +} + +.app-sidebar.collapsed .sidebar-nav a span:not(.sidebar-nav-icon) { + display: none; + opacity: 0; +} + +.app-sidebar.collapsed .sidebar-nav a::before { + content: attr(aria-label); + display: inline-block; + width: auto; + margin-right: var(--space-3); + color: var(--text-sidebar); + font-size: var(--text-xs); + white-space: nowrap; +} + +/* Adjust main content when sidebar is collapsed */ +.app-sidebar.collapsed + .app-main { + margin-left: 60px; +} diff --git a/src/presentation/static/css/pages/chat.css b/src/presentation/static/css/pages/chat.css index d3eeac1..43ccd6a 100644 --- a/src/presentation/static/css/pages/chat.css +++ b/src/presentation/static/css/pages/chat.css @@ -316,6 +316,15 @@ body.chat-page .app-sidebar { cursor: not-allowed; } +#send-btn.is-loading { + background: var(--color-danger, #dc2626); +} + +#send-btn.is-loading:hover { + background: var(--color-danger, #dc2626); + opacity: 0.85; +} + #send-btn svg { width: 18px; height: 18px; diff --git a/src/presentation/static/js/external/dompurify.min.js b/src/presentation/static/js/external/dompurify.min.js new file mode 100644 index 0000000..a30c02b --- /dev/null +++ b/src/presentation/static/js/external/dompurify.min.js @@ -0,0 +1,3 @@ +/*! @license DOMPurify 3.1.6 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.1.6/LICENSE */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).DOMPurify=t()}(this,(function(){"use strict";const{entries:e,setPrototypeOf:t,isFrozen:n,getPrototypeOf:o,getOwnPropertyDescriptor:r}=Object;let{freeze:i,seal:a,create:l}=Object,{apply:c,construct:s}="undefined"!=typeof Reflect&&Reflect;i||(i=function(e){return e}),a||(a=function(e){return e}),c||(c=function(e,t,n){return e.apply(t,n)}),s||(s=function(e,t){return new e(...t)});const u=b(Array.prototype.forEach),m=b(Array.prototype.pop),p=b(Array.prototype.push),f=b(String.prototype.toLowerCase),d=b(String.prototype.toString),h=b(String.prototype.match),g=b(String.prototype.replace),T=b(String.prototype.indexOf),y=b(String.prototype.trim),E=b(Object.prototype.hasOwnProperty),_=b(RegExp.prototype.test),A=(N=TypeError,function(){for(var e=arguments.length,t=new Array(e),n=0;n1?n-1:0),r=1;r2&&void 0!==arguments[2]?arguments[2]:f;t&&t(e,null);let i=o.length;for(;i--;){let t=o[i];if("string"==typeof t){const e=r(t);e!==t&&(n(o)||(o[i]=e),t=e)}e[t]=!0}return e}function R(e){for(let t=0;t/gm),B=a(/\${[\w\W]*}/gm),W=a(/^data-[\-\w.\u00B7-\uFFFF]/),G=a(/^aria-[\-\w]+$/),Y=a(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),j=a(/^(?:\w+script|data):/i),X=a(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),q=a(/^html$/i),$=a(/^[a-z][.\w]*(-[.\w]+)+$/i);var K=Object.freeze({__proto__:null,MUSTACHE_EXPR:H,ERB_EXPR:z,TMPLIT_EXPR:B,DATA_ATTR:W,ARIA_ATTR:G,IS_ALLOWED_URI:Y,IS_SCRIPT_OR_DATA:j,ATTR_WHITESPACE:X,DOCTYPE_NAME:q,CUSTOM_ELEMENT:$});const V=1,Z=3,J=7,Q=8,ee=9,te=function(){return"undefined"==typeof window?null:window};var ne=function t(){let n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:te();const o=e=>t(e);if(o.version="3.1.6",o.removed=[],!n||!n.document||n.document.nodeType!==ee)return o.isSupported=!1,o;let{document:r}=n;const a=r,c=a.currentScript,{DocumentFragment:s,HTMLTemplateElement:N,Node:b,Element:R,NodeFilter:H,NamedNodeMap:z=n.NamedNodeMap||n.MozNamedAttrMap,HTMLFormElement:B,DOMParser:W,trustedTypes:G}=n,j=R.prototype,X=C(j,"cloneNode"),$=C(j,"remove"),ne=C(j,"nextSibling"),oe=C(j,"childNodes"),re=C(j,"parentNode");if("function"==typeof N){const e=r.createElement("template");e.content&&e.content.ownerDocument&&(r=e.content.ownerDocument)}let ie,ae="";const{implementation:le,createNodeIterator:ce,createDocumentFragment:se,getElementsByTagName:ue}=r,{importNode:me}=a;let pe={};o.isSupported="function"==typeof e&&"function"==typeof re&&le&&void 0!==le.createHTMLDocument;const{MUSTACHE_EXPR:fe,ERB_EXPR:de,TMPLIT_EXPR:he,DATA_ATTR:ge,ARIA_ATTR:Te,IS_SCRIPT_OR_DATA:ye,ATTR_WHITESPACE:Ee,CUSTOM_ELEMENT:_e}=K;let{IS_ALLOWED_URI:Ae}=K,Ne=null;const be=S({},[...L,...D,...v,...x,...M]);let Se=null;const Re=S({},[...I,...U,...P,...F]);let we=Object.seal(l(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),Ce=null,Le=null,De=!0,ve=!0,Oe=!1,xe=!0,ke=!1,Me=!0,Ie=!1,Ue=!1,Pe=!1,Fe=!1,He=!1,ze=!1,Be=!0,We=!1,Ge=!0,Ye=!1,je={},Xe=null;const qe=S({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]);let $e=null;const Ke=S({},["audio","video","img","source","image","track"]);let Ve=null;const Ze=S({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),Je="http://www.w3.org/1998/Math/MathML",Qe="http://www.w3.org/2000/svg",et="http://www.w3.org/1999/xhtml";let tt=et,nt=!1,ot=null;const rt=S({},[Je,Qe,et],d);let it=null;const at=["application/xhtml+xml","text/html"];let lt=null,ct=null;const st=r.createElement("form"),ut=function(e){return e instanceof RegExp||e instanceof Function},mt=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(!ct||ct!==e){if(e&&"object"==typeof e||(e={}),e=w(e),it=-1===at.indexOf(e.PARSER_MEDIA_TYPE)?"text/html":e.PARSER_MEDIA_TYPE,lt="application/xhtml+xml"===it?d:f,Ne=E(e,"ALLOWED_TAGS")?S({},e.ALLOWED_TAGS,lt):be,Se=E(e,"ALLOWED_ATTR")?S({},e.ALLOWED_ATTR,lt):Re,ot=E(e,"ALLOWED_NAMESPACES")?S({},e.ALLOWED_NAMESPACES,d):rt,Ve=E(e,"ADD_URI_SAFE_ATTR")?S(w(Ze),e.ADD_URI_SAFE_ATTR,lt):Ze,$e=E(e,"ADD_DATA_URI_TAGS")?S(w(Ke),e.ADD_DATA_URI_TAGS,lt):Ke,Xe=E(e,"FORBID_CONTENTS")?S({},e.FORBID_CONTENTS,lt):qe,Ce=E(e,"FORBID_TAGS")?S({},e.FORBID_TAGS,lt):{},Le=E(e,"FORBID_ATTR")?S({},e.FORBID_ATTR,lt):{},je=!!E(e,"USE_PROFILES")&&e.USE_PROFILES,De=!1!==e.ALLOW_ARIA_ATTR,ve=!1!==e.ALLOW_DATA_ATTR,Oe=e.ALLOW_UNKNOWN_PROTOCOLS||!1,xe=!1!==e.ALLOW_SELF_CLOSE_IN_ATTR,ke=e.SAFE_FOR_TEMPLATES||!1,Me=!1!==e.SAFE_FOR_XML,Ie=e.WHOLE_DOCUMENT||!1,Fe=e.RETURN_DOM||!1,He=e.RETURN_DOM_FRAGMENT||!1,ze=e.RETURN_TRUSTED_TYPE||!1,Pe=e.FORCE_BODY||!1,Be=!1!==e.SANITIZE_DOM,We=e.SANITIZE_NAMED_PROPS||!1,Ge=!1!==e.KEEP_CONTENT,Ye=e.IN_PLACE||!1,Ae=e.ALLOWED_URI_REGEXP||Y,tt=e.NAMESPACE||et,we=e.CUSTOM_ELEMENT_HANDLING||{},e.CUSTOM_ELEMENT_HANDLING&&ut(e.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(we.tagNameCheck=e.CUSTOM_ELEMENT_HANDLING.tagNameCheck),e.CUSTOM_ELEMENT_HANDLING&&ut(e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(we.attributeNameCheck=e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),e.CUSTOM_ELEMENT_HANDLING&&"boolean"==typeof e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements&&(we.allowCustomizedBuiltInElements=e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),ke&&(ve=!1),He&&(Fe=!0),je&&(Ne=S({},M),Se=[],!0===je.html&&(S(Ne,L),S(Se,I)),!0===je.svg&&(S(Ne,D),S(Se,U),S(Se,F)),!0===je.svgFilters&&(S(Ne,v),S(Se,U),S(Se,F)),!0===je.mathMl&&(S(Ne,x),S(Se,P),S(Se,F))),e.ADD_TAGS&&(Ne===be&&(Ne=w(Ne)),S(Ne,e.ADD_TAGS,lt)),e.ADD_ATTR&&(Se===Re&&(Se=w(Se)),S(Se,e.ADD_ATTR,lt)),e.ADD_URI_SAFE_ATTR&&S(Ve,e.ADD_URI_SAFE_ATTR,lt),e.FORBID_CONTENTS&&(Xe===qe&&(Xe=w(Xe)),S(Xe,e.FORBID_CONTENTS,lt)),Ge&&(Ne["#text"]=!0),Ie&&S(Ne,["html","head","body"]),Ne.table&&(S(Ne,["tbody"]),delete Ce.tbody),e.TRUSTED_TYPES_POLICY){if("function"!=typeof e.TRUSTED_TYPES_POLICY.createHTML)throw A('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof e.TRUSTED_TYPES_POLICY.createScriptURL)throw A('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');ie=e.TRUSTED_TYPES_POLICY,ae=ie.createHTML("")}else void 0===ie&&(ie=function(e,t){if("object"!=typeof e||"function"!=typeof e.createPolicy)return null;let n=null;const o="data-tt-policy-suffix";t&&t.hasAttribute(o)&&(n=t.getAttribute(o));const r="dompurify"+(n?"#"+n:"");try{return e.createPolicy(r,{createHTML:e=>e,createScriptURL:e=>e})}catch(e){return console.warn("TrustedTypes policy "+r+" could not be created."),null}}(G,c)),null!==ie&&"string"==typeof ae&&(ae=ie.createHTML(""));i&&i(e),ct=e}},pt=S({},["mi","mo","mn","ms","mtext"]),ft=S({},["foreignobject","annotation-xml"]),dt=S({},["title","style","font","a","script"]),ht=S({},[...D,...v,...O]),gt=S({},[...x,...k]),Tt=function(e){p(o.removed,{element:e});try{re(e).removeChild(e)}catch(t){$(e)}},yt=function(e,t){try{p(o.removed,{attribute:t.getAttributeNode(e),from:t})}catch(e){p(o.removed,{attribute:null,from:t})}if(t.removeAttribute(e),"is"===e&&!Se[e])if(Fe||He)try{Tt(t)}catch(e){}else try{t.setAttribute(e,"")}catch(e){}},Et=function(e){let t=null,n=null;if(Pe)e=""+e;else{const t=h(e,/^[\r\n\t ]+/);n=t&&t[0]}"application/xhtml+xml"===it&&tt===et&&(e=''+e+"");const o=ie?ie.createHTML(e):e;if(tt===et)try{t=(new W).parseFromString(o,it)}catch(e){}if(!t||!t.documentElement){t=le.createDocument(tt,"template",null);try{t.documentElement.innerHTML=nt?ae:o}catch(e){}}const i=t.body||t.documentElement;return e&&n&&i.insertBefore(r.createTextNode(n),i.childNodes[0]||null),tt===et?ue.call(t,Ie?"html":"body")[0]:Ie?t.documentElement:i},_t=function(e){return ce.call(e.ownerDocument||e,e,H.SHOW_ELEMENT|H.SHOW_COMMENT|H.SHOW_TEXT|H.SHOW_PROCESSING_INSTRUCTION|H.SHOW_CDATA_SECTION,null)},At=function(e){return e instanceof B&&("string"!=typeof e.nodeName||"string"!=typeof e.textContent||"function"!=typeof e.removeChild||!(e.attributes instanceof z)||"function"!=typeof e.removeAttribute||"function"!=typeof e.setAttribute||"string"!=typeof e.namespaceURI||"function"!=typeof e.insertBefore||"function"!=typeof e.hasChildNodes)},Nt=function(e){return"function"==typeof b&&e instanceof b},bt=function(e,t,n){pe[e]&&u(pe[e],(e=>{e.call(o,t,n,ct)}))},St=function(e){let t=null;if(bt("beforeSanitizeElements",e,null),At(e))return Tt(e),!0;const n=lt(e.nodeName);if(bt("uponSanitizeElement",e,{tagName:n,allowedTags:Ne}),e.hasChildNodes()&&!Nt(e.firstElementChild)&&_(/<[/\w]/g,e.innerHTML)&&_(/<[/\w]/g,e.textContent))return Tt(e),!0;if(e.nodeType===J)return Tt(e),!0;if(Me&&e.nodeType===Q&&_(/<[/\w]/g,e.data))return Tt(e),!0;if(!Ne[n]||Ce[n]){if(!Ce[n]&&wt(n)){if(we.tagNameCheck instanceof RegExp&&_(we.tagNameCheck,n))return!1;if(we.tagNameCheck instanceof Function&&we.tagNameCheck(n))return!1}if(Ge&&!Xe[n]){const t=re(e)||e.parentNode,n=oe(e)||e.childNodes;if(n&&t){for(let o=n.length-1;o>=0;--o){const r=X(n[o],!0);r.__removalCount=(e.__removalCount||0)+1,t.insertBefore(r,ne(e))}}}return Tt(e),!0}return e instanceof R&&!function(e){let t=re(e);t&&t.tagName||(t={namespaceURI:tt,tagName:"template"});const n=f(e.tagName),o=f(t.tagName);return!!ot[e.namespaceURI]&&(e.namespaceURI===Qe?t.namespaceURI===et?"svg"===n:t.namespaceURI===Je?"svg"===n&&("annotation-xml"===o||pt[o]):Boolean(ht[n]):e.namespaceURI===Je?t.namespaceURI===et?"math"===n:t.namespaceURI===Qe?"math"===n&&ft[o]:Boolean(gt[n]):e.namespaceURI===et?!(t.namespaceURI===Qe&&!ft[o])&&!(t.namespaceURI===Je&&!pt[o])&&!gt[n]&&(dt[n]||!ht[n]):!("application/xhtml+xml"!==it||!ot[e.namespaceURI]))}(e)?(Tt(e),!0):"noscript"!==n&&"noembed"!==n&&"noframes"!==n||!_(/<\/no(script|embed|frames)/i,e.innerHTML)?(ke&&e.nodeType===Z&&(t=e.textContent,u([fe,de,he],(e=>{t=g(t,e," ")})),e.textContent!==t&&(p(o.removed,{element:e.cloneNode()}),e.textContent=t)),bt("afterSanitizeElements",e,null),!1):(Tt(e),!0)},Rt=function(e,t,n){if(Be&&("id"===t||"name"===t)&&(n in r||n in st))return!1;if(ve&&!Le[t]&&_(ge,t));else if(De&&_(Te,t));else if(!Se[t]||Le[t]){if(!(wt(e)&&(we.tagNameCheck instanceof RegExp&&_(we.tagNameCheck,e)||we.tagNameCheck instanceof Function&&we.tagNameCheck(e))&&(we.attributeNameCheck instanceof RegExp&&_(we.attributeNameCheck,t)||we.attributeNameCheck instanceof Function&&we.attributeNameCheck(t))||"is"===t&&we.allowCustomizedBuiltInElements&&(we.tagNameCheck instanceof RegExp&&_(we.tagNameCheck,n)||we.tagNameCheck instanceof Function&&we.tagNameCheck(n))))return!1}else if(Ve[t]);else if(_(Ae,g(n,Ee,"")));else if("src"!==t&&"xlink:href"!==t&&"href"!==t||"script"===e||0!==T(n,"data:")||!$e[e]){if(Oe&&!_(ye,g(n,Ee,"")));else if(n)return!1}else;return!0},wt=function(e){return"annotation-xml"!==e&&h(e,_e)},Ct=function(e){bt("beforeSanitizeAttributes",e,null);const{attributes:t}=e;if(!t)return;const n={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:Se};let r=t.length;for(;r--;){const i=t[r],{name:a,namespaceURI:l,value:c}=i,s=lt(a);let p="value"===a?c:y(c);if(n.attrName=s,n.attrValue=p,n.keepAttr=!0,n.forceKeepAttr=void 0,bt("uponSanitizeAttribute",e,n),p=n.attrValue,Me&&_(/((--!?|])>)|<\/(style|title)/i,p)){yt(a,e);continue}if(n.forceKeepAttr)continue;if(yt(a,e),!n.keepAttr)continue;if(!xe&&_(/\/>/i,p)){yt(a,e);continue}ke&&u([fe,de,he],(e=>{p=g(p,e," ")}));const f=lt(e.nodeName);if(Rt(f,s,p)){if(!We||"id"!==s&&"name"!==s||(yt(a,e),p="user-content-"+p),ie&&"object"==typeof G&&"function"==typeof G.getAttributeType)if(l);else switch(G.getAttributeType(f,s)){case"TrustedHTML":p=ie.createHTML(p);break;case"TrustedScriptURL":p=ie.createScriptURL(p)}try{l?e.setAttributeNS(l,a,p):e.setAttribute(a,p),At(e)?Tt(e):m(o.removed)}catch(e){}}}bt("afterSanitizeAttributes",e,null)},Lt=function e(t){let n=null;const o=_t(t);for(bt("beforeSanitizeShadowDOM",t,null);n=o.nextNode();)bt("uponSanitizeShadowNode",n,null),St(n)||(n.content instanceof s&&e(n.content),Ct(n));bt("afterSanitizeShadowDOM",t,null)};return o.sanitize=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=null,r=null,i=null,l=null;if(nt=!e,nt&&(e="\x3c!--\x3e"),"string"!=typeof e&&!Nt(e)){if("function"!=typeof e.toString)throw A("toString is not a function");if("string"!=typeof(e=e.toString()))throw A("dirty is not a string, aborting")}if(!o.isSupported)return e;if(Ue||mt(t),o.removed=[],"string"==typeof e&&(Ye=!1),Ye){if(e.nodeName){const t=lt(e.nodeName);if(!Ne[t]||Ce[t])throw A("root node is forbidden and cannot be sanitized in-place")}}else if(e instanceof b)n=Et("\x3c!----\x3e"),r=n.ownerDocument.importNode(e,!0),r.nodeType===V&&"BODY"===r.nodeName||"HTML"===r.nodeName?n=r:n.appendChild(r);else{if(!Fe&&!ke&&!Ie&&-1===e.indexOf("<"))return ie&&ze?ie.createHTML(e):e;if(n=Et(e),!n)return Fe?null:ze?ae:""}n&&Pe&&Tt(n.firstChild);const c=_t(Ye?e:n);for(;i=c.nextNode();)St(i)||(i.content instanceof s&&Lt(i.content),Ct(i));if(Ye)return e;if(Fe){if(He)for(l=se.call(n.ownerDocument);n.firstChild;)l.appendChild(n.firstChild);else l=n;return(Se.shadowroot||Se.shadowrootmode)&&(l=me.call(a,l,!0)),l}let m=Ie?n.outerHTML:n.innerHTML;return Ie&&Ne["!doctype"]&&n.ownerDocument&&n.ownerDocument.doctype&&n.ownerDocument.doctype.name&&_(q,n.ownerDocument.doctype.name)&&(m="\n"+m),ke&&u([fe,de,he],(e=>{m=g(m,e," ")})),ie&&ze?ie.createHTML(m):m},o.setConfig=function(){mt(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}),Ue=!0},o.clearConfig=function(){ct=null,Ue=!1},o.isValidAttribute=function(e,t,n){ct||mt({});const o=lt(e),r=lt(t);return Rt(o,r,n)},o.addHook=function(e,t){"function"==typeof t&&(pe[e]=pe[e]||[],p(pe[e],t))},o.removeHook=function(e){if(pe[e])return m(pe[e])},o.removeHooks=function(e){pe[e]&&(pe[e]=[])},o.removeAllHooks=function(){pe={}},o}();return ne})); +//# sourceMappingURL=purify.min.js.map diff --git a/src/presentation/static/js/internal/chat.js b/src/presentation/static/js/internal/chat.js index 145ef32..4d87c75 100644 --- a/src/presentation/static/js/internal/chat.js +++ b/src/presentation/static/js/internal/chat.js @@ -1,17 +1,4 @@ (() => { - function showToast(message, type) { - if (!type) type = "info"; - const container = document.getElementById("toast-container"); - if (!container) return; - const toast = document.createElement("div"); - toast.className = `toast ${type}`; - toast.textContent = message; - container.appendChild(toast); - setTimeout(() => { - toast.remove(); - }, 3000); - } - function init() { const messagesEl = document.getElementById("chat-messages"); const chatForm = document.getElementById("chat-form"); @@ -28,12 +15,60 @@ marked.setOptions({ gfm: true, breaks: true }); } + const MD_TAGS = [ + "a", + "b", + "i", + "em", + "strong", + "code", + "pre", + "br", + "hr", + "p", + "blockquote", + "ul", + "ol", + "li", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "table", + "thead", + "tbody", + "tfoot", + "tr", + "th", + "td", + "span", + ]; + const MD_ATTRS = ["href", "title", "colspan", "rowspan"]; + + if (typeof DOMPurify !== "undefined") { + DOMPurify.addHook("afterSanitizeAttributes", (node) => { + if (node.tagName === "A") { + node.setAttribute("target", "_blank"); + node.setAttribute("rel", "noopener noreferrer"); + } + }); + } + function renderMarkdown(text) { if (typeof marked !== "undefined") { const normalized = text .replace(/([^\n])\n*(#{1,6}\s)/g, "$1\n\n$2") .replace(/([^\n])\n*(\|)/g, "$1\n\n$2"); - return marked.parse(normalized); + const html = marked.parse(normalized); + if (typeof DOMPurify !== "undefined") { + return DOMPurify.sanitize(html, { + ALLOWED_TAGS: MD_TAGS, + ALLOWED_ATTR: MD_ATTRS, + }); + } + return html; } const div = document.createElement("div"); div.textContent = text; @@ -229,13 +264,19 @@ } function setLoading(loading) { - sendBtn.disabled = loading; - sendBtn.innerHTML = loading - ? '' - : ''; if (loading) { + sendBtn.innerHTML = + ''; + sendBtn.title = "Stop"; + sendBtn.setAttribute("aria-label", "Stop"); + sendBtn.classList.add("is-loading"); showTyping(); } else { + sendBtn.innerHTML = + ''; + sendBtn.title = "Send"; + sendBtn.setAttribute("aria-label", "Send"); + sendBtn.classList.remove("is-loading"); hideTyping(); } } @@ -267,7 +308,7 @@ const w = document.createElement("div"); w.className = "chat-welcome"; w.innerHTML = - "

SigmaHQ RAG

Ask questions about Sigma detection rules

"; + "

SigmaForge

Ask questions about Sigma detection rules

"; messagesEl.appendChild(w); welcome = w; } else { @@ -281,7 +322,17 @@ } if (newChatBtn) { - newChatBtn.addEventListener("click", clearChat); + newChatBtn.addEventListener("click", async () => { + const hasMessages = !!messagesEl.querySelector(".message"); + if (!hasMessages) { + clearChat(); + return; + } + const ok = await showConfirm( + "Start a new chat? This clears the current history.", + ); + if (ok) clearChat(); + }); } loadPrompts(); @@ -473,6 +524,15 @@ this.style.height = "auto"; this.style.height = `${this.scrollHeight}px`; }); + + input.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + chatForm.requestSubmit(); + } + }); + + input.focus(); } document.addEventListener("DOMContentLoaded", init); diff --git a/src/presentation/static/js/internal/local.js b/src/presentation/static/js/internal/local.js new file mode 100644 index 0000000..0f04282 --- /dev/null +++ b/src/presentation/static/js/internal/local.js @@ -0,0 +1,212 @@ +// Local files management + +const uploadZone = document.getElementById("upload-zone"); +const fileInput = document.getElementById("file-input"); +const localFilesBody = document.getElementById("local-files-body"); +const totalCount = document.getElementById("total-count"); +const pendingCount = document.getElementById("pending-count"); +const embeddedCount = document.getElementById("embedded-count"); + +// Drag and drop handlers +uploadZone.addEventListener("click", () => fileInput.click()); + +uploadZone.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + fileInput.click(); + } +}); + +uploadZone.addEventListener("dragover", (e) => { + e.preventDefault(); + uploadZone.classList.add("dragover"); +}); + +uploadZone.addEventListener("dragleave", () => { + uploadZone.classList.remove("dragover"); +}); + +uploadZone.addEventListener("drop", (e) => { + e.preventDefault(); + uploadZone.classList.remove("dragover"); + if (e.dataTransfer.files.length > 0) { + handleFiles(e.dataTransfer.files); + } +}); + +fileInput.addEventListener("change", (e) => { + if (e.target.files.length > 0) { + handleFiles(e.target.files); + e.target.value = ""; + } +}); + +async function formatFileSize(bytes) { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / k ** i).toFixed(1)) + " " + sizes[i]; +} + +function getFileType(contentType) { + if (!contentType || contentType === "unknown") + return 'Unknown'; + return contentType.charAt(0).toUpperCase() + contentType.slice(1); +} + +function getEmbedStatusBadge(embedStatus) { + const statusMap = { + discovery: { class: "discovery", label: "Pending" }, + embedded: { class: "embedded", label: "Embedded" }, + error: { class: "error", label: "Error" }, + }; + const config = statusMap[embedStatus] || { + class: "discovery", + label: embedStatus || "Unknown", + }; + return `${config.label}`; +} + +function formatTimestamp(isoStr) { + if (!isoStr) return "-"; + const d = new Date(isoStr); + return ( + d.toLocaleDateString("fr-FR") + + " " + + d.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" }) + ); +} + +async function loadLocalFiles() { + try { + const response = await fetch( + "/api/v1/files/local/list?limit=1000&offset=0", + ); + const data = await response.json(); + + if (!data.success || !data.data) { + localFilesBody.innerHTML = + 'No local files found.'; + totalCount.textContent = "0"; + pendingCount.textContent = "0"; + embeddedCount.textContent = "0"; + return; + } + + const files = data.data; + totalCount.textContent = data.total || files.length; + pendingCount.textContent = files.filter( + (f) => f.embed_status === "discovery", + ).length; + embeddedCount.textContent = files.filter( + (f) => f.embed_status === "embedded", + ).length; + + if (files.length === 0) { + localFilesBody.innerHTML = + 'No local files found. Upload a file to get started.'; + return; + } + + localFilesBody.innerHTML = files + .map( + (file, idx) => ` + + ${file.file_name || file.title || "?"} + ${getFileType(file.content_type)} + ${file.file_size ? formatFileSize(file.file_size) : "0 B"} + ${getEmbedStatusBadge(file.embed_status)} + + + `, + ) + .join(""); + } catch (err) { + console.error("Failed to load local files:", err); + localFilesBody.innerHTML = + 'Error loading files.'; + } +} + +async function deleteFile(idx, originalUrl) { + if ( + !(await showConfirm( + "Are you sure you want to delete this file? This cannot be undone.", + )) + ) + return; + + try { + const response = await fetch( + `/api/v1/files/local/delete?file_path=${encodeURIComponent(originalUrl)}`, + ); + const data = await response.json(); + + if (data.success) { + showToast("File deleted"); + loadLocalFiles(); + } else { + showToast("Error: " + (data.error || "Unknown error"), "error"); + } + } catch (err) { + console.error("Failed to delete file:", err); + showToast("Error deleting file.", "error"); + } +} + +async function handleFiles(fileList) { + const uploadProgress = document.getElementById("upload-progress"); + const statusMessage = document.getElementById("status-message"); + const pb = new ProgressBar({ + container: uploadProgress, + fill: document.getElementById("progress-fill"), + text: document.getElementById("status-message"), + }); + + pb.show(); + pb.setText(`Uploading ${fileList.length} file(s)...`); + + let successCount = 0; + let errorCount = 0; + + for (let i = 0; i < fileList.length; i++) { + const file = fileList[i]; + pb.setProgress(((i + 1) / fileList.length) * 100); + pb.setText(`Uploading ${i + 1}/${fileList.length}: ${file.name}`); + + try { + const formData = new FormData(); + formData.append("file", file); + formData.append("collection_name", "local"); + + const response = await fetch("/api/v1/files/local/add", { + method: "POST", + body: formData, + }); + + const data = await response.json(); + if (data.success) { + successCount++; + } else { + errorCount++; + console.error("Upload failed:", data.error); + } + } catch (err) { + errorCount++; + console.error("Upload error:", err); + } + } + + pb.complete(); + pb.setText(`Complete: ${successCount} uploaded, ${errorCount} errors`); + + setTimeout(() => { + pb.hide(); + loadLocalFiles(); + }, 2000); +} + +// Load files on page load +loadLocalFiles(); +setInterval(loadLocalFiles, 30000); diff --git a/src/presentation/static/js/internal/notifications.js b/src/presentation/static/js/internal/notifications.js index 252f0ae..d2f2ee3 100644 --- a/src/presentation/static/js/internal/notifications.js +++ b/src/presentation/static/js/internal/notifications.js @@ -1,6 +1,14 @@ (() => { const TOAST_TTL = 3500; + function escHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + function createContainer() { let c = document.getElementById("toast-container"); if (!c) { diff --git a/src/presentation/static/js/internal/repo-browser.js b/src/presentation/static/js/internal/repo-browser.js index 5e86ccc..6bb7f22 100644 --- a/src/presentation/static/js/internal/repo-browser.js +++ b/src/presentation/static/js/internal/repo-browser.js @@ -114,13 +114,13 @@ class RepoBrowser { ); const result = await response.json(); if (result.success) { - alert("Syncing repository..."); + showToast("Syncing repository..."); this.loadRepos(); } else { - alert(result.error || "Failed to sync"); + showToast(result.error || "Failed to sync", "error"); } } catch { - alert("Failed to sync repository"); + showToast("Failed to sync repository", "error"); } } @@ -143,10 +143,10 @@ class RepoBrowser { } this.loadRepos(); } else { - alert(result.error || "Failed to delete"); + showToast(result.error || "Failed to delete", "error"); } } catch { - alert("Failed to delete repository"); + showToast("Failed to delete repository", "error"); } } @@ -235,8 +235,14 @@ class RepoBrowser { } closeModal() { - document.getElementById("add-repo-modal").style.display = "none"; - document.getElementById("add-repo-form").reset(); + const modal = document.getElementById("add-repo-modal"); + if (modal) { + modal.style.display = "none"; + modal.setAttribute("aria-hidden", "true"); + const triggerBtn = document.getElementById("add-repo-btn"); + if (triggerBtn) triggerBtn.focus(); + document.getElementById("add-repo-form").reset(); + } } isWorkerActive(status) { @@ -360,7 +366,7 @@ class RepoBrowser { document.getElementById("repo-branch").value.trim() || "main"; if (!url) { - alert("Please enter a repository"); + showToast("Please enter a repository", "error"); return; } @@ -382,10 +388,10 @@ class RepoBrowser { this.closeModal(); this.loadRepos(); } else { - alert(result.error || "Failed to add repository"); + showToast(result.error || "Failed to add repository", "error"); } } catch { - alert("Failed to add repository"); + showToast("Failed to add repository", "error"); } } @@ -418,7 +424,27 @@ class RepoBrowser { // add-repo modal on("add-repo-btn", "click", () => { const modal = byId("add-repo-modal"); - if (modal) modal.style.display = "block"; + if (modal) { + modal.style.display = "block"; + modal.setAttribute("aria-hidden", "false"); + // Trap focus inside modal + const firstFocusable = modal.querySelector('input[type="text"]'); + if (firstFocusable) { + firstFocusable.focus(); + firstFocusable.select(); + } + // Close on Escape + const handleKeydown = (e) => { + if (e.key === "Escape") { + modal.style.display = "none"; + modal.setAttribute("aria-hidden", "true"); + const triggerBtn = document.getElementById("add-repo-btn"); + if (triggerBtn) triggerBtn.focus(); + document.removeEventListener("keydown", handleKeydown); + } + }; + document.addEventListener("keydown", handleKeydown); + } }); on("modal-cancel-btn", "click", () => this.closeModal()); @@ -443,13 +469,13 @@ class RepoBrowser { }); const result = await response.json(); if (result.success) { - alert("Sync started for all repositories..."); + showToast("Sync started for all repositories..."); self.loadRepos(); } else { - alert(result.error || "Failed to sync all"); + showToast(result.error || "Failed to sync all", "error"); } } catch { - alert("Failed to sync all repositories"); + showToast("Failed to sync all repositories", "error"); } finally { this.disabled = false; this.textContent = "Sync All"; diff --git a/src/presentation/static/js/internal/theme.js b/src/presentation/static/js/internal/theme.js index bad076c..3e470a8 100644 --- a/src/presentation/static/js/internal/theme.js +++ b/src/presentation/static/js/internal/theme.js @@ -1,5 +1,5 @@ (() => { - const STORAGE_KEY = "sigmahqrag-theme"; + const STORAGE_KEY = "sigmaforge-theme"; function getTheme() { return localStorage.getItem(STORAGE_KEY) || "light"; diff --git a/src/presentation/templates/chat/index.html.j2 b/src/presentation/templates/chat/index.html.j2 index ff1f41b..72eff0a 100644 --- a/src/presentation/templates/chat/index.html.j2 +++ b/src/presentation/templates/chat/index.html.j2 @@ -1,6 +1,6 @@ {% extends "shared/layout.html.j2" %} -{% block title %}Chat - SigmaHQ RAG{% endblock %} +{% block title %}Chat — SigmaForge{% endblock %} {% block body_class %}chat-page{% endblock %} @@ -11,7 +11,7 @@ {% block sidebar %}
- +
- + No
@@ -177,9 +177,9 @@
-
@@ -188,9 +188,9 @@
-
@@ -243,9 +243,9 @@
-
@@ -254,9 +254,9 @@
-
@@ -297,7 +297,7 @@

Embedding E5

- Dimension mismatch ??? The active embedding model dimension () differs from the Qdrant collection (). Vectors will be rejected until the collection is recreated. + Dimension mismatch: The active embedding model dimension () differs from the Qdrant collection (). Vectors will be rejected until the collection is recreated.
diff --git a/src/presentation/templates/dashboard/base.html.j2 b/src/presentation/templates/dashboard/base.html.j2 index 46c153b..322cf20 100644 --- a/src/presentation/templates/dashboard/base.html.j2 +++ b/src/presentation/templates/dashboard/base.html.j2 @@ -1,6 +1,6 @@ {% extends "shared/layout.html.j2" %} -{% block title %}Dashboard - Sigmahqrag{% endblock %} +{% block title %}Dashboard — SigmaForge{% endblock %} {% block sidebar_title %}Dashboard{% endblock %} {% block sidebar_subtitle %}Database overview & debug{% endblock %} diff --git a/src/presentation/templates/data/base.html.j2 b/src/presentation/templates/data/base.html.j2 index ed9615c..8d3428a 100644 --- a/src/presentation/templates/data/base.html.j2 +++ b/src/presentation/templates/data/base.html.j2 @@ -1,7 +1,7 @@ {# Data section layout - extends shared layout #} {% extends "shared/layout.html.j2" %} -{% block title %}Data Sources - Sigmahqrag{% endblock %} +{% block title %}Data Sources — SigmaForge{% endblock %} {% block styles %} {{ super() }} diff --git a/src/presentation/templates/data/github.html.j2 b/src/presentation/templates/data/github.html.j2 index 375db90..1bd4a47 100644 --- a/src/presentation/templates/data/github.html.j2 +++ b/src/presentation/templates/data/github.html.j2 @@ -1,57 +1,19 @@ {% extends "data/base.html.j2" %} -{% from "shared/_dir_browser.html.j2" import dir_browser_card %} +{% from "shared/_repo_section.html.j2" import repo_section %} -{% block title %}GitHub - Data Sources{% endblock %} +{% block title %}GitHub Repositories — SigmaForge{% endblock %} {% block page_content %}

GitHub Repositories

Manage GitHub repositories for Sigma rule indexing.

-
-
- - - - - -
- -
- - -
- - - - +{{ repo_section( + intro_text="Click \"Browse\" on a repository to view and select directories for indexing.", + modal_h2="Add GitHub Repository", + url_placeholder="SigmaHQ/sigma", + dir_browser_id="github", + dir_browser_title="Select Directories to Index" +) }} {% endblock %} {% block scripts %} diff --git a/src/presentation/templates/data/local.html.j2 b/src/presentation/templates/data/local.html.j2 index b5fda13..870013e 100644 --- a/src/presentation/templates/data/local.html.j2 +++ b/src/presentation/templates/data/local.html.j2 @@ -1,107 +1,10 @@ {% extends "data/base.html.j2" %} -{% block title %}Local Files - Data Sources{% endblock %} +{% block title %}Local Files — SigmaForge{% endblock %} {% block styles %} {{ super() }} - + {% endblock %} {% block page_content %} @@ -123,7 +26,7 @@
-
+

📁 Drag & Drop files here

or click to browse (supported: .md, .txt, .yml, .yaml, .json, etc.)

@@ -159,181 +62,5 @@ {% endblock %} {% block scripts %} - -{% endblock %} + +{% endblock %} \ No newline at end of file diff --git a/src/presentation/templates/data/prompts.html.j2 b/src/presentation/templates/data/prompts.html.j2 index 21a52c6..301509e 100644 --- a/src/presentation/templates/data/prompts.html.j2 +++ b/src/presentation/templates/data/prompts.html.j2 @@ -1,6 +1,6 @@ {% extends "data/base.html.j2" %} -{% block title %}Prompts | Data Sources | SigmaHQ RAG{% endblock %} +{% block title %}Prompts — SigmaForge{% endblock %} {% block content %}
@@ -35,20 +35,20 @@
- - + +
- - + +
- - + +
- - + +