################################################################################
#                                                                              #
#   ██████╗ ██████╗ ██████╗ ███████╗██████╗  █████╗ ███████╗███████╗          #
#  ██╔════╝██╔═══██╗██╔══██╗██╔════╝██╔══██╗██╔══██╗██╔════╝██╔════╝          #
#  ██║     ██║   ██║██║  ██║█████╗  ██████╔╝███████║███████╗█████╗            #
#  ██║     ██║   ██║██║  ██║██╔══╝  ██╔══██╗██╔══██║╚════██║██╔══╝            #
#  ╚██████╗╚██████╔╝██████╔╝███████╗██████╔╝██║  ██║███████║███████╗          #
#   ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═════╝ ╚═╝  ╚═╝╚══════╝╚══════╝          #
#                                                                              #
#          COMPLETE CODEBASE DUMP — EVERY FILE, EVERY LINE                     #
#                                                                              #
################################################################################

==============================================================================
 PROJECT CODEBASE — FULL SOURCE DUMP
==============================================================================

 Generated:       2026-04-10 19:55:42
 Source Dir:       /Users/mahmoudaglan/son-of-anton
 Total Files:      69
 Total Lines:      17892
 Total Size:       687KB

 THIS FILE CONTAINS THE COMPLETE CODEBASE INCLUDING:
   • All source code files (every language found)
   • All configuration files (json, yaml, toml, xml, ini, etc.)
   • All environment files (.env, .env.local, .env.production, etc.)
   • All Docker files (Dockerfile, docker-compose.yml, .dockerignore)
   • All CI/CD configs (Jenkinsfile, GitHub Actions, etc.)
   • All build configs (webpack, vite, tsconfig, Makefile, etc.)
   • All package manifests (package.json, Cargo.toml, go.mod, etc.)
   • All lock files (package-lock.json, yarn.lock, etc.)
   • All documentation (README, CHANGELOG, LICENSE, etc.)
   • All scripts (shell, python, etc.)

 STRUCTURE OF THIS FILE:
   1. DIRECTORY TREE
   2. FILE MAP (indexed table of every file)
   3. FILE TYPE BREAKDOWN (stats by extension)
   4. SKIPPED FILES (binary/oversized — for transparency)
   5. COMPLETE FILE CONTENTS (every file printed in full)

==============================================================================


╔══════════════════════════════════════════════════════════════════════════════╗
║  SECTION 1: DIRECTORY TREE                                                  ║
╚══════════════════════════════════════════════════════════════════════════════╝

(For a visual tree: brew install tree)

Collected file paths:
  .dockerignore
  .env.example
  DEPLOY_GUIDE.md
  Dockerfile
  atchfiles.sh
  backend/auth.py
  backend/config.py
  backend/database.py
  backend/main.py
  backend/migrations/005_attachments.sql
  backend/models.py
  backend/routes/admin_routes.py
  backend/routes/attachment_routes.py
  backend/routes/attachment_routes_15.py
  backend/routes/attachment_routes_16.py
  backend/routes/attachments.py
  backend/routes/auth_routes.py
  backend/routes/chat_routes.py
  backend/routes/export_routes.py
  backend/routes/files_routes.py
  backend/routes/gitlab_routes.py
  backend/routes/knowledge_routes.py
  backend/routes/messages_patch.py
  backend/seed.py
  backend/services/attachment_service.py
  backend/services/bedrock_service.py
  backend/services/code_analyzer.py
  backend/services/code_extractor.py
  backend/services/docx_service.py
  backend/services/file_processor.py
  backend/services/generation_manager.py
  backend/services/gitlab_service.py
  backend/services/memory_service.py
  backend/services/pptx_service.py
  backend/services/rag_service.py
  backend/services/web_search_service.py
  backend/system_prompt.py
  create-project.ps1
  fixsoa.sh
  frontend/index.html
  frontend/package-lock.json
  frontend/package.json
  frontend/postcss.config.js
  frontend/src/App.jsx
  frontend/src/api.js
  frontend/src/components/AttachmentPreview.jsx
  frontend/src/components/ChatView.jsx
  frontend/src/components/CodeBlock.jsx
  frontend/src/components/ColorPalette.jsx
  frontend/src/components/FileUploadButton.jsx
  frontend/src/components/LivePreview.jsx
  frontend/src/components/MermaidDiagram.jsx
  frontend/src/components/MessageBubble.jsx
  frontend/src/components/Sidebar.jsx
  frontend/src/components/UIPreview.jsx
  frontend/src/index.css
  frontend/src/main.jsx
  frontend/src/pages/AdminPage.jsx
  frontend/src/pages/ChatPage.jsx
  frontend/src/pages/GitLabPage.jsx
  frontend/src/pages/KnowledgePage.jsx
  frontend/src/pages/LoginPage.jsx
  frontend/src/store.jsx
  frontend/src/streamManager.js
  frontend/tailwind.config.js
  frontend/vite.config.js
  main.py
  requirements.txt
  warmup.py


╔══════════════════════════════════════════════════════════════════════════════╗
║  SECTION 2: FILE MAP — INDEXED LIST OF ALL 69 FILES
╚══════════════════════════════════════════════════════════════════════════════╝

 Each file below appears in SECTION 5 with full contents.
 Use [###] index to jump to any file.

 INDEX   LINES    SIZE      FILE PATH
 -----   -----    ------    ----------------------------------------------
 [001]  28       282B      .dockerignore
 [002]  30       983B      .env.example
 [003]  23       749B      DEPLOY_GUIDE.md
 [004]  65       1KB       Dockerfile
 [005]  1805     72KB      atchfiles.sh
 [006]  219      8KB       backend/auth.py
 [007]  106      5KB       backend/config.py
 [008]  28       777B      backend/database.py
 [009]  142      6KB       backend/main.py
 [010]  17       818B      backend/migrations/005_attachments.sql
 [011]  221      8KB       backend/models.py
 [012]  267      10KB      backend/routes/admin_routes.py
 [013]  115      4KB       backend/routes/attachment_routes.py
 [014]  158      4KB       backend/routes/attachment_routes_15.py
 [015]  164      5KB       backend/routes/attachment_routes_16.py
 [016]  156      4KB       backend/routes/attachments.py
 [017]  103      3KB       backend/routes/auth_routes.py
 [018]  349      12KB      backend/routes/chat_routes.py
 [019]  58       1KB       backend/routes/export_routes.py
 [020]  62       1KB       backend/routes/files_routes.py
 [021]  581      20KB      backend/routes/gitlab_routes.py
 [022]  196      8KB       backend/routes/knowledge_routes.py
 [023]  127      3KB       backend/routes/messages_patch.py
 [024]  70       2KB       backend/seed.py
 [025]  260      8KB       backend/services/attachment_service.py
 [026]  242      8KB       backend/services/bedrock_service.py
 [027]  656      23KB      backend/services/code_analyzer.py
 [028]  56       1KB       backend/services/code_extractor.py
 [029]  318      10KB      backend/services/docx_service.py
 [030]  165      5KB       backend/services/file_processor.py
 [031]  222      11KB      backend/services/generation_manager.py
 [032]  636      23KB      backend/services/gitlab_service.py
 [033]  99       3KB       backend/services/memory_service.py
 [034]  292      9KB       backend/services/pptx_service.py
 [035]  110      3KB       backend/services/rag_service.py
 [036]  351      13KB      backend/services/web_search_service.py
 [037]  129      4KB       backend/system_prompt.py
 [038]  182      7KB       create-project.ps1
 [039]  269      11KB      fixsoa.sh
 [040]  32       1KB       frontend/index.html
 [041]  4552     158KB     frontend/package-lock.json
 [042]  27       666B      frontend/package.json
 [043]  5        80B       frontend/postcss.config.js
 [044]  53       1KB       frontend/src/App.jsx
 [045]  281      12KB      frontend/src/api.js
 [046]  158      4KB       frontend/src/components/AttachmentPreview.jsx
 [047]  252      25KB      frontend/src/components/ChatView.jsx
 [048]  145      6KB       frontend/src/components/CodeBlock.jsx
 [049]  53       2KB       frontend/src/components/ColorPalette.jsx
 [050]  81       1KB       frontend/src/components/FileUploadButton.jsx
 [051]  130      4KB       frontend/src/components/LivePreview.jsx
 [052]  116      4KB       frontend/src/components/MermaidDiagram.jsx
 [053]  309      13KB      frontend/src/components/MessageBubble.jsx
 [054]  119      5KB       frontend/src/components/Sidebar.jsx
 [055]  640      23KB      frontend/src/components/UIPreview.jsx
 [056]  284      8KB       frontend/src/index.css
 [057]  15       409B      frontend/src/main.jsx
 [058]  299      17KB      frontend/src/pages/AdminPage.jsx
 [059]  100      3KB       frontend/src/pages/ChatPage.jsx
 [060]  320      20KB      frontend/src/pages/GitLabPage.jsx
 [061]  384      19KB      frontend/src/pages/KnowledgePage.jsx
 [062]  136      6KB       frontend/src/pages/LoginPage.jsx
 [063]  81       3KB       frontend/src/store.jsx
 [064]  158      5KB       frontend/src/streamManager.js
 [065]  26       714B      frontend/tailwind.config.js
 [066]  23       596B      frontend/vite.config.js
 [067]  16       528B      main.py
 [068]  14       274B      requirements.txt
 [069]  6        221B      warmup.py


╔══════════════════════════════════════════════════════════════════════════════╗
║  SECTION 3: FILE TYPE BREAKDOWN                                             ║
╚══════════════════════════════════════════════════════════════════════════════╝

 EXTENSION/TYPE             COUNT
 ───────────────────────    ─────
 .py                        33
 .jsx                       18
 .js                        5
 .sh                        2
 .json                      2
 .txt                       1
 .sql                       1
 .ps1                       1
 .md                        1
 .html                      1
 .example                   1
 .dockerignore              1
 .css                       1
 .Dockerfile                1


╔══════════════════════════════════════════════════════════════════════════════╗
║  SECTION 4: SKIPPED FILES (listed for completeness)                         ║
╚══════════════════════════════════════════════════════════════════════════════╝

 Binary files (not text):
   ⊘ .gitignore
   ⊘ backend/__init__.py
   ⊘ backend/routes/__init__.py
   ⊘ backend/services/__init__.py


╔══════════════════════════════════════════════════════════════════════════════╗
║  SECTION 5: COMPLETE FILE CONTENTS                                          ║
║                                                                              ║
║  Every file printed in full with:                                            ║
║    • Clear start/end markers                                                 ║
║    • File path, size, line count in the header                               ║
║    • Language hint for syntax context                                        ║
╚══════════════════════════════════════════════════════════════════════════════╝


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [001/69]: .dockerignore
│ LANGUAGE: plaintext | LINES: 28 | SIZE: 282 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	# === Node ===
     2	frontend/node_modules
     3	frontend/dist
     4	frontend/.vite
     5	
     6	# === Python ===
     7	**/__pycache__
     8	**/*.pyc
     9	**/*.pyo
    10	*.egg-info
    11	
    12	# === Git ===
    13	.git
    14	.gitignore
    15	
    16	# === Dev files ===
    17	.env
    18	.env.local
    19	*.md
    20	create-project.ps1
    21	main.py
    22	**/.DS_Store
    23	
    24	# === IDE ===
    25	.idea
    26	.vscode
    27	*.swp
    28	*.swo
│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [001]: .dockerignore


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [002/69]: .env.example
│ LANGUAGE: dotenv | LINES: 30 | SIZE: 983 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	# ============================================
     2	# Son of Anton — Environment Variables
     3	# ============================================
     4	
     5	# AWS Bedrock API Key (Bearer Token)
     6	BEDROCK_API_KEY=ABSKQmVkcm9ja0FQSUtleS12ZGdrLWF0LTc2OTE4NzYxNDEyODpnc0ZzdmVGUlRVendqcVpJUURzR0Nzb2RSZ09hK1JLTnJTekFBY3FJQjBqL0F1UXVyekxic3VmaEtpRT0=
     7	
     8	# JWT secret for authentication tokens (change this!)
     9	JWT_SECRET=CreatedSystemsOverloadedFunctionsBySonOfAnton
    10	
    11	# Default superadmin password (change this!)
    12	SUPERADMIN_PASSWORD=admin123
    13	
    14	# AWS Region for Bedrock
    15	AWS_REGION=eu-central-1
    16	
    17	# Primary model
    18	PRIMARY_MODEL=eu.anthropic.claude-opus-4-6-v1
    19	
    20	# Fast model for summaries / title generation
    21	FAST_MODEL=eu.anthropic.claude-haiku-4-5-20251001-v1:0
    22	
    23	# Database URL (default: SQLite in /data)
    24	# DATABASE_URL=sqlite:////data/sonofanton.db
    25	# For PostgreSQL: postgresql://user:pass@host:5432/dbname
    26	
    27	# Default monthly token quota for new users
    28	DEFAULT_QUOTA=2000000
    29	
    30	# Max file upload size in MB
    31	MAX_UPLOAD_MB=50│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [002]: .env.example


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [003/69]: DEPLOY_GUIDE.md
│ LANGUAGE: markdown | LINES: 23 | SIZE: 749 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	# 🔥 Son of Anton — Deployment Guide (Zero Tech Friendly)
     2	
     3	## What You're Building
     4	A private AI chat app powered by Claude Opus 4.6 on AWS Bedrock, with:
     5	- Multiple chat sessions with memory
     6	- Downloadable code files from AI responses
     7	- Knowledge base / RAG (upload docs, AI learns from them)
     8	- User management with quotas
     9	- Superadmin dashboard
    10	
    11	---
    12	
    13	## STEP 1 — Get the Code onto Your GitLab
    14	
    15	1. On your computer, create a folder called `son-of-anton`
    16	2. Copy ALL the files from this guide into that folder (keep the folder structure!)
    17	3. Open a terminal in that folder and run:
    18	
    19	```bash
    20	git init
    21	git remote add origin https://YOUR-GITLAB-SERVER/YOUR-USERNAME/son-of-anton.git
    22	git add .
    23	git commit -m "Initial commit"
    24	git push -u origin main│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [003]: DEPLOY_GUIDE.md


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [004/69]: Dockerfile
│ LANGUAGE: dockerfile | LINES: 65 | SIZE: 1959 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	# ============================================
     2	# Stage 1: Build React Frontend
     3	# ============================================
     4	FROM node:20-alpine AS frontend-build
     5	
     6	WORKDIR /build/frontend
     7	
     8	# Print versions for diagnostic purposes
     9	RUN echo "=== Node/npm versions ===" && node --version && npm --version
    10	
    11	# Nuke NODE_ENV — CapRover/Docker can inject production
    12	# which prevents devDependencies (vite, tailwind) from installing
    13	ENV NODE_ENV=
    14	
    15	COPY frontend/package.json frontend/package-lock.json* ./
    16	
    17	# npm install (not ci) — more forgiving of lockfile quirks
    18	# --legacy-peer-deps avoids peer dep conflicts
    19	# --prefer-offline uses cache when available
    20	RUN npm install --legacy-peer-deps 2>&1 && \
    21	  echo "=== install done ===" && \
    22	  ls node_modules/.bin/vite && \
    23	  ./node_modules/.bin/vite --version
    24	
    25	COPY frontend/ ./
    26	
    27	RUN echo "=== starting vite build ===" && \
    28	  sh -c 'NODE_ENV=production ./node_modules/.bin/vite build --logLevel info 2>&1; \
    29	    CODE=$?; \
    30	    echo "=== Vite exit code: $CODE ==="; \
    31	    exit $CODE'
    32	
    33	RUN echo "=== dist ===" && ls -la dist/
    34	
    35	# ============================================
    36	# Stage 2: Python Backend + Serve Frontend
    37	# ============================================
    38	FROM python:3.11-slim
    39	
    40	RUN apt-get update && apt-get install -y --no-install-recommends \
    41	  build-essential \
    42	  ffmpeg \
    43	  && rm -rf /var/lib/apt/lists/*
    44	
    45	WORKDIR /app
    46	
    47	COPY requirements.txt .
    48	RUN pip install --no-cache-dir -r requirements.txt
    49	
    50	COPY backend/ ./backend/
    51	
    52	COPY --from=frontend-build /build/frontend/dist ./frontend/dist
    53	
    54	RUN echo "=== Frontend ===" && ls -la frontend/dist/ && \
    55	  echo "=== Assets ===" && ls -la frontend/dist/assets/ || true
    56	
    57	COPY warmup.py /tmp/warmup.py
    58	RUN python /tmp/warmup.py && rm /tmp/warmup.py
    59	
    60	RUN mkdir -p /data/chromadb /data/uploads /data/uploads/chat_attachments
    61	
    62	ENV PYTHONUNBUFFERED=1
    63	
    64	EXPOSE 80
    65	
    66	CMD ["python", "-m", "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "80", "--workers", "1"]│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [004]: Dockerfile


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [005/69]: atchfiles.sh
│ LANGUAGE: bash | LINES: 1805 | SIZE: 73738 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	#!/usr/bin/env bash
     2	set -euo pipefail
     3	
     4	# ═══════════════════════════════════════════════════════════════
     5	# 🔥 Son of Anton — Patch All Modified/New Files
     6	#    Run from the son-of-anton root directory
     7	# ═══════════════════════════════════════════════════════════════
     8	
     9	PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
    10	cd "$PROJECT_DIR"
    11	
    12	RED='\033[0;31m'
    13	GREEN='\033[0;32m'
    14	CYAN='\033[0;36m'
    15	BOLD='\033[1m'
    16	NC='\033[0m'
    17	
    18	ok()   { echo -e "  ${GREEN}✔${NC} $1"; }
    19	step() { echo -e "  ${CYAN}▸${NC} Writing $1..."; }
    20	
    21	echo ""
    22	echo -e "${CYAN}═══════════════════════════════════════════════════${NC}"
    23	echo -e "${BOLD}  Patching all files...${NC}"
    24	echo -e "${CYAN}═══════════════════════════════════════════════════${NC}"
    25	echo ""
    26	
    27	# ────────────────────────────────────────
    28	# .dockerignore
    29	# ────────────────────────────────────────
    30	step ".dockerignore"
    31	cat > .dockerignore << 'ENDOFFILE'
    32	# === Node ===
    33	frontend/node_modules
    34	frontend/dist
    35	frontend/.vite
    36	
    37	# === Python ===
    38	**/__pycache__
    39	**/*.pyc
    40	**/*.pyo
    41	*.egg-info
    42	
    43	# === Git ===
    44	.git
    45	.gitignore
    46	
    47	# === Dev files ===
    48	.env
    49	.env.local
    50	*.md
    51	create-project.ps1
    52	main.py
    53	**/.DS_Store
    54	
    55	# === IDE ===
    56	.idea
    57	.vscode
    58	*.swp
    59	*.swo
    60	ENDOFFILE
    61	ok ".dockerignore"
    62	
    63	# ────────────────────────────────────────
    64	# backend/config.py
    65	# ────────────────────────────────────────
    66	step "backend/config.py"
    67	cat > backend/config.py << 'ENDOFFILE'
    68	"""
    69	Application configuration — reads from environment variables.
    70	"""
    71	
    72	import os
    73	import secrets
    74	
    75	BEDROCK_API_KEY: str = os.getenv(
    76	    "BEDROCK_API_KEY",
    77	    os.getenv("AWS_BEARER_TOKEN_BEDROCK", ""),
    78	)
    79	AWS_REGION: str = os.getenv("AWS_REGION", "eu-central-1")
    80	PRIMARY_MODEL: str = os.getenv("PRIMARY_MODEL", "eu.anthropic.claude-opus-4-6-v1")
    81	FAST_MODEL: str = os.getenv("FAST_MODEL", "eu.anthropic.claude-haiku-4-5-20251001-v1:0")
    82	
    83	JWT_SECRET: str = os.getenv("JWT_SECRET", secrets.token_hex(32))
    84	JWT_ALGORITHM: str = "HS256"
    85	JWT_EXPIRY_HOURS: int = 72
    86	
    87	SUPERADMIN_PASSWORD: str = os.getenv("SUPERADMIN_PASSWORD", "admin123")
    88	
    89	DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite:////data/sonofanton.db")
    90	
    91	CHROMADB_PATH: str = os.getenv("CHROMADB_PATH", "/data/chromadb")
    92	UPLOAD_PATH: str = os.getenv("UPLOAD_PATH", "/data/uploads")
    93	ATTACHMENT_PATH: str = os.getenv("ATTACHMENT_PATH", "/data/uploads/chat_attachments")
    94	
    95	DEFAULT_QUOTA: int = int(os.getenv("DEFAULT_QUOTA", "2000000"))
    96	MAX_UPLOAD_BYTES: int = int(os.getenv("MAX_UPLOAD_MB", "50")) * 1024 * 1024
    97	MAX_ATTACHMENT_BYTES: int = int(os.getenv("MAX_ATTACHMENT_MB", "25")) * 1024 * 1024
    98	
    99	MAX_IMAGE_DIMENSION: int = 1568
   100	MAX_VIDEO_FRAMES: int = 6
   101	
   102	BEDROCK_ENDPOINT: str = (
   103	    f"https://bedrock-runtime.{AWS_REGION}.amazonaws.com"
   104	)
   105	ENDOFFILE
   106	ok "backend/config.py"
   107	
   108	# ────────────────────────────────────────
   109	# backend/models.py
   110	# ────────────────────────────────────────
   111	step "backend/models.py"
   112	cat > backend/models.py << 'ENDOFFILE'
   113	"""
   114	SQLAlchemy ORM models.
   115	"""
   116	
   117	from datetime import datetime, timedelta
   118	from uuid import uuid4
   119	
   120	from sqlalchemy import (
   121	    Column, String, Text, Boolean, BigInteger, Integer, DateTime, ForeignKey,
   122	)
   123	from sqlalchemy.orm import relationship
   124	
   125	from backend.database import Base
   126	
   127	
   128	def new_id() -> str:
   129	    return str(uuid4())
   130	
   131	
   132	def next_month() -> datetime:
   133	    now = datetime.utcnow()
   134	    if now.month == 12:
   135	        return datetime(now.year + 1, 1, 1)
   136	    return datetime(now.year, now.month + 1, 1)
   137	
   138	
   139	class User(Base):
   140	    __tablename__ = "users"
   141	
   142	    id = Column(String(36), primary_key=True, default=new_id)
   143	    username = Column(String(50), unique=True, nullable=False, index=True)
   144	    email = Column(String(120), unique=True, nullable=False)
   145	    password_hash = Column(String(200), nullable=False)
   146	    role = Column(String(20), default="user")
   147	    is_active = Column(Boolean, default=True)
   148	    quota_tokens_monthly = Column(BigInteger, default=2_000_000)
   149	    tokens_used_this_month = Column(BigInteger, default=0)
   150	    quota_reset_date = Column(DateTime, default=next_month)
   151	    created_at = Column(DateTime, default=datetime.utcnow)
   152	
   153	    chats = relationship("Chat", back_populates="user", cascade="all,delete-orphan")
   154	
   155	
   156	class Chat(Base):
   157	    __tablename__ = "chats"
   158	
   159	    id = Column(String(36), primary_key=True, default=new_id)
   160	    user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
   161	    title = Column(String(200), default="New Chat")
   162	    model = Column(String(100), default="eu.anthropic.claude-opus-4-6-v1")
   163	    knowledge_base_id = Column(String(36), nullable=True)
   164	    max_tokens = Column(Integer, default=4096)
   165	    reasoning_budget = Column(Integer, default=0)
   166	    created_at = Column(DateTime, default=datetime.utcnow)
   167	    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
   168	
   169	    user = relationship("User", back_populates="chats")
   170	    messages = relationship(
   171	        "Message", back_populates="chat",
   172	        cascade="all,delete-orphan", order_by="Message.created_at",
   173	    )
   174	    attachments = relationship(
   175	        "ChatAttachment", back_populates="chat",
   176	        cascade="all,delete-orphan",
   177	    )
   178	
   179	
   180	class Message(Base):
   181	    __tablename__ = "messages"
   182	
   183	    id = Column(String(36), primary_key=True, default=new_id)
   184	    chat_id = Column(String(36), ForeignKey("chats.id", ondelete="CASCADE"), nullable=False)
   185	    role = Column(String(20), nullable=False)
   186	    content = Column(Text, default="")
   187	    thinking_content = Column(Text, nullable=True)
   188	    input_tokens = Column(Integer, default=0)
   189	    output_tokens = Column(Integer, default=0)
   190	    created_at = Column(DateTime, default=datetime.utcnow)
   191	
   192	    chat = relationship("Chat", back_populates="messages")
   193	    attachments = relationship("ChatAttachment", back_populates="message")
   194	
   195	
   196	class ChatAttachment(Base):
   197	    __tablename__ = "chat_attachments"
   198	
   199	    id = Column(String(36), primary_key=True, default=new_id)
   200	    chat_id = Column(String(36), ForeignKey("chats.id", ondelete="CASCADE"), nullable=False)
   201	    message_id = Column(String(36), ForeignKey("messages.id", ondelete="SET NULL"), nullable=True)
   202	    filename = Column(String(200), nullable=False)
   203	    original_filename = Column(String(200), nullable=False)
   204	    mime_type = Column(String(100), nullable=False)
   205	    file_type = Column(String(20), nullable=False)
   206	    file_size = Column(Integer, default=0)
   207	    storage_path = Column(String(500), nullable=False)
   208	    text_extract = Column(Text, nullable=True)
   209	    created_at = Column(DateTime, default=datetime.utcnow)
   210	
   211	    chat = relationship("Chat", back_populates="attachments")
   212	    message = relationship("Message", back_populates="attachments")
   213	
   214	
   215	class KnowledgeBase(Base):
   216	    __tablename__ = "knowledge_bases"
   217	
   218	    id = Column(String(36), primary_key=True, default=new_id)
   219	    user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
   220	    name = Column(String(200), nullable=False)
   221	    description = Column(Text, default="")
   222	    document_count = Column(Integer, default=0)
   223	    chunk_count = Column(Integer, default=0)
   224	    total_characters = Column(BigInteger, default=0)
   225	    created_at = Column(DateTime, default=datetime.utcnow)
   226	
   227	
   228	class KnowledgeDocument(Base):
   229	    __tablename__ = "knowledge_documents"
   230	
   231	    id = Column(String(36), primary_key=True, default=new_id)
   232	    knowledge_base_id = Column(
   233	        String(36), ForeignKey("knowledge_bases.id", ondelete="CASCADE"), nullable=False,
   234	    )
   235	    filename = Column(String(200), nullable=False)
   236	    file_size = Column(Integer, default=0)
   237	    chunk_count = Column(Integer, default=0)
   238	    created_at = Column(DateTime, default=datetime.utcnow)
   239	ENDOFFILE
   240	ok "backend/models.py"
   241	
   242	# ────────────────────────────────────────
   243	# backend/main.py
   244	# ────────────────────────────────────────
   245	step "backend/main.py"
   246	cat > backend/main.py << 'ENDOFFILE'
   247	"""
   248	Son of Anton — Main FastAPI Application
   249	"""
   250	
   251	import os
   252	from pathlib import Path
   253	from contextlib import asynccontextmanager
   254	
   255	from fastapi import FastAPI, HTTPException
   256	from fastapi.staticfiles import StaticFiles
   257	from fastapi.responses import FileResponse
   258	from fastapi.middleware.cors import CORSMiddleware
   259	
   260	from backend.database import engine, Base
   261	from backend.seed import seed_superadmin
   262	from backend.routes.auth_routes import router as auth_router
   263	from backend.routes.chat_routes import router as chat_router
   264	from backend.routes.admin_routes import router as admin_router
   265	from backend.routes.knowledge_routes import router as knowledge_router
   266	from backend.routes.files_routes import router as files_router
   267	from backend.routes.attachment_routes import router as attachment_router
   268	from backend.services.bedrock_service import close_http_client
   269	
   270	
   271	def _run_migrations():
   272	    """Add new columns/tables to existing DB if they're missing."""
   273	    from sqlalchemy import inspect, text
   274	    try:
   275	        inspector = inspect(engine)
   276	        existing_tables = inspector.get_table_names()
   277	
   278	        if "chats" in existing_tables:
   279	            columns = {c["name"] for c in inspector.get_columns("chats")}
   280	            with engine.connect() as conn:
   281	                if "max_tokens" not in columns:
   282	                    conn.execute(text("ALTER TABLE chats ADD COLUMN max_tokens INTEGER DEFAULT 4096"))
   283	                    print("  Added chats.max_tokens column")
   284	                if "reasoning_budget" not in columns:
   285	                    conn.execute(text("ALTER TABLE chats ADD COLUMN reasoning_budget INTEGER DEFAULT 0"))
   286	                    print("  Added chats.reasoning_budget column")
   287	                conn.commit()
   288	
   289	        if "chat_attachments" not in existing_tables:
   290	            from backend.models import ChatAttachment
   291	            ChatAttachment.__table__.create(bind=engine, checkfirst=True)
   292	            print("  Created chat_attachments table")
   293	
   294	    except Exception as e:
   295	        print(f"  Migration note: {e}")
   296	
   297	
   298	@asynccontextmanager
   299	async def lifespan(app: FastAPI):
   300	    Base.metadata.create_all(bind=engine)
   301	    _run_migrations()
   302	    seed_superadmin()
   303	    print("Son of Anton is online.")
   304	    yield
   305	    await close_http_client()
   306	    print("Son of Anton shutting down.")
   307	
   308	
   309	app = FastAPI(
   310	    title="Son of Anton",
   311	    description="Avatar of All Elements of Code",
   312	    version="2.0.0",
   313	    lifespan=lifespan,
   314	)
   315	
   316	app.add_middleware(
   317	    CORSMiddleware,
   318	    allow_origins=["*"],
   319	    allow_credentials=True,
   320	    allow_methods=["*"],
   321	    allow_headers=["*"],
   322	)
   323	
   324	app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
   325	app.include_router(chat_router, prefix="/api/chats", tags=["Chats"])
   326	app.include_router(admin_router, prefix="/api/admin", tags=["Admin"])
   327	app.include_router(knowledge_router, prefix="/api/knowledge", tags=["Knowledge"])
   328	app.include_router(files_router, prefix="/api/files", tags=["Files"])
   329	app.include_router(attachment_router, prefix="/api", tags=["Attachments"])
   330	
   331	FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
   332	
   333	if (FRONTEND_DIR / "assets").exists():
   334	    app.mount(
   335	        "/assets",
   336	        StaticFiles(directory=str(FRONTEND_DIR / "assets")),
   337	        name="static-assets",
   338	    )
   339	
   340	
   341	@app.get("/{full_path:path}", include_in_schema=False)
   342	async def serve_frontend(full_path: str):
   343	    if full_path.startswith("api"):
   344	        raise HTTPException(status_code=404, detail="Not found")
   345	    file_path = FRONTEND_DIR / full_path
   346	    if full_path and file_path.is_file():
   347	        return FileResponse(str(file_path))
   348	    index = FRONTEND_DIR / "index.html"
   349	    if index.is_file():
   350	        return FileResponse(str(index))
   351	    return {"message": "Son of Anton API is running. Frontend not built."}
   352	ENDOFFILE
   353	ok "backend/main.py"
   354	
   355	# ────────────────────────────────────────
   356	# backend/services/attachment_service.py
   357	# ────────────────────────────────────────
   358	step "backend/services/attachment_service.py"
   359	cat > backend/services/attachment_service.py << 'ENDOFFILE'
   360	"""
   361	Attachment processing service.
   362	Handles images, videos (frame extraction), PDFs, and text files.
   363	"""
   364	
   365	import os
   366	import io
   367	import base64
   368	import shutil
   369	import subprocess
   370	import tempfile
   371	import mimetypes
   372	from pathlib import Path
   373	from uuid import uuid4
   374	from typing import Optional
   375	
   376	from backend import config
   377	
   378	os.makedirs(config.ATTACHMENT_PATH, exist_ok=True)
   379	
   380	IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"}
   381	VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv", ".m4v"}
   382	PDF_EXTENSIONS = {".pdf"}
   383	TEXT_EXTENSIONS = {
   384	    ".txt", ".md", ".py", ".js", ".ts", ".jsx", ".tsx", ".cs", ".java",
   385	    ".cpp", ".c", ".h", ".hpp", ".go", ".rs", ".rb", ".php", ".swift",
   386	    ".kt", ".lua", ".gd", ".html", ".css", ".scss", ".json", ".yaml",
   387	    ".yml", ".xml", ".toml", ".ini", ".cfg", ".conf", ".sh", ".bash",
   388	    ".sql", ".r", ".dart", ".vue", ".svelte", ".csv", ".log", ".env",
   389	}
   390	
   391	IMAGE_MIMES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
   392	VIDEO_MIMES = {"video/mp4", "video/quicktime", "video/x-msvideo", "video/webm"}
   393	
   394	
   395	def classify_file(filename, mime):
   396	    ext = Path(filename).suffix.lower()
   397	    if ext in IMAGE_EXTENSIONS or mime in IMAGE_MIMES:
   398	        return "image"
   399	    if ext in VIDEO_EXTENSIONS or mime in VIDEO_MIMES:
   400	        return "video"
   401	    if ext in PDF_EXTENSIONS or mime == "application/pdf":
   402	        return "document"
   403	    return "text"
   404	
   405	
   406	def get_mime_type(filename, content_type=None):
   407	    if content_type and content_type != "application/octet-stream":
   408	        return content_type
   409	    mime, _ = mimetypes.guess_type(filename)
   410	    return mime or "application/octet-stream"
   411	
   412	
   413	def save_attachment(chat_id, filename, content, content_type=None):
   414	    mime = get_mime_type(filename, content_type)
   415	    file_type = classify_file(filename, mime)
   416	    attachment_id = str(uuid4())
   417	
   418	    chat_dir = os.path.join(config.ATTACHMENT_PATH, chat_id)
   419	    os.makedirs(chat_dir, exist_ok=True)
   420	
   421	    safe_name = Path(filename).name.replace(" ", "_")
   422	    stored_name = f"{attachment_id}_{safe_name}"
   423	    storage_path = os.path.join(chat_dir, stored_name)
   424	
   425	    with open(storage_path, "wb") as f:
   426	        f.write(content)
   427	
   428	    text_extract = None
   429	    if file_type == "text":
   430	        text_extract = _extract_text_content(storage_path)
   431	    elif file_type == "document":
   432	        text_extract = _extract_pdf_text(storage_path)
   433	
   434	    return {
   435	        "id": attachment_id,
   436	        "filename": stored_name,
   437	        "original_filename": filename,
   438	        "mime_type": mime,
   439	        "file_type": file_type,
   440	        "file_size": len(content),
   441	        "storage_path": storage_path,
   442	        "text_extract": text_extract,
   443	    }
   444	
   445	
   446	def delete_attachment_file(storage_path):
   447	    try:
   448	        if os.path.exists(storage_path):
   449	            os.remove(storage_path)
   450	    except Exception:
   451	        pass
   452	
   453	
   454	def delete_chat_attachments(chat_id):
   455	    chat_dir = os.path.join(config.ATTACHMENT_PATH, chat_id)
   456	    if os.path.isdir(chat_dir):
   457	        shutil.rmtree(chat_dir, ignore_errors=True)
   458	
   459	
   460	def build_claude_content_blocks(attachments):
   461	    blocks = []
   462	    for att in attachments:
   463	        try:
   464	            result = _process_single_attachment(att)
   465	            if isinstance(result, list):
   466	                blocks.extend(result)
   467	            elif result:
   468	                blocks.append(result)
   469	        except Exception as e:
   470	            blocks.append({
   471	                "type": "text",
   472	                "text": f"[Failed to process {att.original_filename}: {str(e)}]",
   473	            })
   474	    return blocks
   475	
   476	
   477	def _process_single_attachment(att):
   478	    if att.file_type == "image":
   479	        return _build_image_block(att)
   480	    elif att.file_type == "video":
   481	        return _build_video_blocks(att)
   482	    elif att.file_type == "document":
   483	        return _build_document_block(att)
   484	    elif att.file_type == "text":
   485	        return _build_text_block(att)
   486	    return None
   487	
   488	
   489	def _build_image_block(att):
   490	    data = _read_and_resize_image(att.storage_path, att.mime_type)
   491	    mime = att.mime_type if att.mime_type in IMAGE_MIMES else "image/jpeg"
   492	    return {
   493	        "type": "image",
   494	        "source": {"type": "base64", "media_type": mime, "data": data},
   495	    }
   496	
   497	
   498	def _build_video_blocks(att):
   499	    frames = _extract_video_frames(att.storage_path)
   500	    if not frames:
   501	        return [{"type": "text", "text": f"[Video: {att.original_filename} - could not extract frames]"}]
   502	    blocks = [{"type": "text", "text": f"[Video: {att.original_filename} - {len(frames)} frames extracted]"}]
   503	    for frame_b64 in frames:
   504	        blocks.append({
   505	            "type": "image",
   506	            "source": {"type": "base64", "media_type": "image/jpeg", "data": frame_b64},
   507	        })
   508	    return blocks
   509	
   510	
   511	def _build_document_block(att):
   512	    if att.mime_type == "application/pdf":
   513	        with open(att.storage_path, "rb") as f:
   514	            data = base64.b64encode(f.read()).decode("utf-8")
   515	        return {
   516	            "type": "document",
   517	            "source": {"type": "base64", "media_type": "application/pdf", "data": data},
   518	        }
   519	    return _build_text_block(att)
   520	
   521	
   522	def _build_text_block(att):
   523	    text = att.text_extract or _extract_text_content(att.storage_path)
   524	    if not text:
   525	        text = f"[Could not extract text from {att.original_filename}]"
   526	    return {
   527	        "type": "text",
   528	        "text": f"--- File: {att.original_filename} ---\n{text}\n--- End of {att.original_filename} ---",
   529	    }
   530	
   531	
   532	def _read_and_resize_image(path, mime_type):
   533	    try:
   534	        from PIL import Image
   535	        img = Image.open(path)
   536	        if img.mode in ("RGBA", "LA", "P"):
   537	            bg = Image.new("RGB", img.size, (255, 255, 255))
   538	            if img.mode == "P":
   539	                img = img.convert("RGBA")
   540	            bg.paste(img, mask=img.split()[-1] if "A" in img.mode else None)
   541	            img = bg
   542	        elif img.mode != "RGB":
   543	            img = img.convert("RGB")
   544	        mx = config.MAX_IMAGE_DIMENSION
   545	        if img.width > mx or img.height > mx:
   546	            ratio = min(mx / img.width, mx / img.height)
   547	            img = img.resize((int(img.width * ratio), int(img.height * ratio)), Image.LANCZOS)
   548	        buf = io.BytesIO()
   549	        fmt = "PNG" if mime_type == "image/png" else "JPEG"
   550	        kwargs = {"quality": 85} if fmt == "JPEG" else {}
   551	        img.save(buf, format=fmt, **kwargs)
   552	        return base64.b64encode(buf.getvalue()).decode("utf-8")
   553	    except ImportError:
   554	        with open(path, "rb") as f:
   555	            return base64.b64encode(f.read()).decode("utf-8")
   556	    except Exception:
   557	        with open(path, "rb") as f:
   558	            return base64.b64encode(f.read()).decode("utf-8")
   559	
   560	
   561	def _extract_video_frames(video_path):
   562	    if not shutil.which("ffmpeg") or not shutil.which("ffprobe"):
   563	        return []
   564	    frames = []
   565	    try:
   566	        result = subprocess.run(
   567	            ["ffprobe", "-v", "error", "-show_entries", "format=duration",
   568	             "-of", "default=noprint_wrappers=1:nokey=1", video_path],
   569	            capture_output=True, text=True, timeout=30,
   570	        )
   571	        duration = float(result.stdout.strip() or "0")
   572	        if duration <= 0:
   573	            return []
   574	        max_frames = config.MAX_VIDEO_FRAMES
   575	        with tempfile.TemporaryDirectory() as tmpdir:
   576	            interval = duration / (max_frames + 1)
   577	            for i in range(max_frames):
   578	                ts = interval * (i + 1)
   579	                out = os.path.join(tmpdir, f"frame_{i}.jpg")
   580	                subprocess.run(
   581	                    ["ffmpeg", "-ss", str(ts), "-i", video_path, "-vframes", "1",
   582	                     "-vf", f"scale='min({config.MAX_IMAGE_DIMENSION},iw)':'min({config.MAX_IMAGE_DIMENSION},ih)':force_original_aspect_ratio=decrease",
   583	                     "-q:v", "3", out],
   584	                    capture_output=True, timeout=30,
   585	                )
   586	                if os.path.exists(out) and os.path.getsize(out) > 0:
   587	                    with open(out, "rb") as f:
   588	                        frames.append(base64.b64encode(f.read()).decode("utf-8"))
   589	    except Exception:
   590	        pass
   591	    return frames
   592	
   593	
   594	def _extract_text_content(path):
   595	    try:
   596	        with open(path, "r", encoding="utf-8") as f:
   597	            return f.read(500_000)
   598	    except UnicodeDecodeError:
   599	        try:
   600	            with open(path, "r", encoding="latin-1") as f:
   601	                return f.read(500_000)
   602	        except Exception:
   603	            return None
   604	    except Exception:
   605	        return None
   606	
   607	
   608	def _extract_pdf_text(path):
   609	    try:
   610	        from PyPDF2 import PdfReader
   611	        reader = PdfReader(path)
   612	        pages = []
   613	        for page in reader.pages[:100]:
   614	            text = page.extract_text()
   615	            if text:
   616	                pages.append(text)
   617	        return "\n\n".join(pages) if pages else None
   618	    except Exception:
   619	        return None
   620	ENDOFFILE
   621	ok "backend/services/attachment_service.py"
   622	
   623	# ────────────────────────────────────────
   624	# backend/routes/attachment_routes.py
   625	# ────────────────────────────────────────
   626	step "backend/routes/attachment_routes.py"
   627	cat > backend/routes/attachment_routes.py << 'ENDOFFILE'
   628	"""
   629	Chat attachment upload, serve, and delete routes.
   630	"""
   631	
   632	import os
   633	from typing import Optional
   634	
   635	from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Request
   636	from fastapi.responses import FileResponse
   637	from sqlalchemy.orm import Session
   638	
   639	from backend.database import get_db
   640	from backend.models import User, Chat, ChatAttachment
   641	from backend.auth import get_current_user, decode_token
   642	from backend.services import attachment_service
   643	from backend.config import MAX_ATTACHMENT_BYTES
   644	
   645	router = APIRouter()
   646	
   647	
   648	def _get_user_flexible(request: Request, db: Session, token_param: Optional[str] = None) -> User:
   649	    raw_token = None
   650	    auth_header = request.headers.get("authorization", "")
   651	    if auth_header.startswith("Bearer "):
   652	        raw_token = auth_header[7:]
   653	    if not raw_token and token_param:
   654	        raw_token = token_param
   655	    if not raw_token:
   656	        raise HTTPException(401, "Authentication required")
   657	    payload = decode_token(raw_token)
   658	    user = db.query(User).filter(User.id == payload["sub"]).first()
   659	    if not user or not user.is_active:
   660	        raise HTTPException(401, "User not found or inactive")
   661	    return user
   662	
   663	
   664	@router.post("/chats/{chat_id}/attachments")
   665	async def upload_attachments(
   666	    chat_id: str,
   667	    files: list[UploadFile] = File(...),
   668	    user: User = Depends(get_current_user),
   669	    db: Session = Depends(get_db),
   670	):
   671	    chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
   672	    if not chat:
   673	        raise HTTPException(404, "Chat not found")
   674	
   675	    results = []
   676	    for file in files:
   677	        filename = file.filename or "file"
   678	        try:
   679	            content = await file.read()
   680	            if len(content) > MAX_ATTACHMENT_BYTES:
   681	                results.append({"error": f"Too large: {filename}"})
   682	                continue
   683	
   684	            meta = attachment_service.save_attachment(
   685	                chat_id=chat_id, filename=filename,
   686	                content=content, content_type=file.content_type,
   687	            )
   688	
   689	            att = ChatAttachment(
   690	                id=meta["id"], chat_id=chat_id,
   691	                filename=meta["filename"],
   692	                original_filename=meta["original_filename"],
   693	                mime_type=meta["mime_type"],
   694	                file_type=meta["file_type"],
   695	                file_size=meta["file_size"],
   696	                storage_path=meta["storage_path"],
   697	                text_extract=meta.get("text_extract"),
   698	            )
   699	            db.add(att)
   700	            db.commit()
   701	            db.refresh(att)
   702	            results.append(_att_dict(att))
   703	        except Exception as e:
   704	            results.append({"error": f"Failed: {filename}: {str(e)}"})
   705	
   706	    return {"attachments": results}
   707	
   708	
   709	@router.get("/attachments/{attachment_id}/file")
   710	def serve_attachment(
   711	    attachment_id: str,
   712	    request: Request,
   713	    token: Optional[str] = Query(None),
   714	    db: Session = Depends(get_db),
   715	):
   716	    user = _get_user_flexible(request, db, token)
   717	    att = db.query(ChatAttachment).filter(ChatAttachment.id == attachment_id).first()
   718	    if not att:
   719	        raise HTTPException(404, "Attachment not found")
   720	    chat = db.query(Chat).filter(Chat.id == att.chat_id).first()
   721	    if not chat or (chat.user_id != user.id and user.role != "superadmin"):
   722	        raise HTTPException(403, "Access denied")
   723	    if not os.path.exists(att.storage_path):
   724	        raise HTTPException(404, "File not found on disk")
   725	    return FileResponse(att.storage_path, media_type=att.mime_type, filename=att.original_filename)
   726	
   727	
   728	@router.delete("/attachments/{attachment_id}")
   729	def delete_attachment(
   730	    attachment_id: str,
   731	    user: User = Depends(get_current_user),
   732	    db: Session = Depends(get_db),
   733	):
   734	    att = db.query(ChatAttachment).filter(ChatAttachment.id == attachment_id).first()
   735	    if not att:
   736	        raise HTTPException(404)
   737	    chat = db.query(Chat).filter(Chat.id == att.chat_id).first()
   738	    if not chat or (chat.user_id != user.id and user.role != "superadmin"):
   739	        raise HTTPException(403)
   740	    attachment_service.delete_attachment_file(att.storage_path)
   741	    db.delete(att)
   742	    db.commit()
   743	    return {"ok": True}
   744	
   745	
   746	def _att_dict(att):
   747	    return {
   748	        "id": att.id, "chat_id": att.chat_id, "message_id": att.message_id,
   749	        "filename": att.filename, "original_filename": att.original_filename,
   750	        "mime_type": att.mime_type, "file_type": att.file_type,
   751	        "file_size": att.file_size, "created_at": str(att.created_at),
   752	    }
   753	ENDOFFILE
   754	ok "backend/routes/attachment_routes.py"
   755	
   756	# ────────────────────────────────────────
   757	# backend/routes/chat_routes.py
   758	# ────────────────────────────────────────
   759	step "backend/routes/chat_routes.py"
   760	cat > backend/routes/chat_routes.py << 'ENDOFFILE'
   761	"""
   762	Chat CRUD and message streaming with multimodal attachment support.
   763	"""
   764	
   765	import json
   766	from datetime import datetime
   767	from pydantic import BaseModel
   768	from typing import Optional
   769	
   770	from fastapi import APIRouter, Depends, HTTPException
   771	from fastapi.responses import StreamingResponse
   772	from sqlalchemy.orm import Session
   773	
   774	from backend.database import get_db, SessionLocal
   775	from backend.models import User, Chat, Message, ChatAttachment
   776	from backend.auth import get_current_user
   777	from backend.system_prompt import build_full_prompt
   778	from backend.services import bedrock_service, memory_service, rag_service, attachment_service
   779	
   780	router = APIRouter()
   781	
   782	
   783	class CreateChatBody(BaseModel):
   784	    title: str = "New Chat"
   785	    model: str = "eu.anthropic.claude-opus-4-6-v1"
   786	    knowledge_base_id: Optional[str] = None
   787	    max_tokens: int = 4096
   788	    reasoning_budget: int = 0
   789	
   790	
   791	class UpdateChatBody(BaseModel):
   792	    title: Optional[str] = None
   793	    model: Optional[str] = None
   794	    max_tokens: Optional[int] = None
   795	    reasoning_budget: Optional[int] = None
   796	    knowledge_base_id: Optional[str] = None
   797	
   798	
   799	class SendMessageBody(BaseModel):
   800	    content: str
   801	    model: Optional[str] = None
   802	    max_tokens: int = 4096
   803	    reasoning_budget: int = 0
   804	    knowledge_base_id: Optional[str] = None
   805	    attachment_ids: list[str] = []
   806	
   807	
   808	@router.get("")
   809	def list_chats(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
   810	    chats = db.query(Chat).filter(Chat.user_id == user.id).order_by(Chat.updated_at.desc()).all()
   811	    return [_chat_dict(c) for c in chats]
   812	
   813	
   814	@router.post("")
   815	def create_chat(body: CreateChatBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
   816	    chat = Chat(
   817	        user_id=user.id, title=body.title, model=body.model,
   818	        knowledge_base_id=body.knowledge_base_id or None,
   819	        max_tokens=body.max_tokens, reasoning_budget=body.reasoning_budget,
   820	    )
   821	    db.add(chat)
   822	    db.commit()
   823	    db.refresh(chat)
   824	    return _chat_dict(chat)
   825	
   826	
   827	@router.get("/{chat_id}")
   828	def get_chat(chat_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
   829	    chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
   830	    if not chat:
   831	        raise HTTPException(404, "Chat not found")
   832	    return _chat_dict(chat)
   833	
   834	
   835	@router.put("/{chat_id}")
   836	def update_chat(chat_id: str, body: UpdateChatBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
   837	    chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
   838	    if not chat:
   839	        raise HTTPException(404)
   840	    if body.title is not None:
   841	        chat.title = body.title
   842	    if body.model is not None:
   843	        chat.model = body.model
   844	    if body.max_tokens is not None:
   845	        chat.max_tokens = body.max_tokens
   846	    if body.reasoning_budget is not None:
   847	        chat.reasoning_budget = body.reasoning_budget
   848	    if body.knowledge_base_id is not None:
   849	        chat.knowledge_base_id = body.knowledge_base_id or None
   850	    db.commit()
   851	    return _chat_dict(chat)
   852	
   853	
   854	@router.delete("/{chat_id}")
   855	def delete_chat(chat_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
   856	    chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
   857	    if not chat:
   858	        raise HTTPException(404)
   859	    attachment_service.delete_chat_attachments(chat_id)
   860	    db.delete(chat)
   861	    db.commit()
   862	    return {"ok": True}
   863	
   864	
   865	@router.get("/{chat_id}/messages")
   866	def get_messages(chat_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
   867	    chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
   868	    if not chat:
   869	        raise HTTPException(404)
   870	    msgs = []
   871	    for m in chat.messages:
   872	        d = _msg_dict(m)
   873	        atts = db.query(ChatAttachment).filter(ChatAttachment.message_id == m.id).all()
   874	        d["attachments"] = [_att_brief(a) for a in atts]
   875	        msgs.append(d)
   876	    return msgs
   877	
   878	
   879	@router.post("/{chat_id}/messages")
   880	async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends(get_current_user)):
   881	    user_id = user.id
   882	
   883	    async def generate():
   884	        db = SessionLocal()
   885	        try:
   886	            chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user_id).first()
   887	            if not chat:
   888	                yield _sse({"type": "error", "message": "Chat not found"})
   889	                return
   890	
   891	            db_user = db.query(User).filter(User.id == user_id).first()
   892	
   893	            now = datetime.utcnow()
   894	            if db_user.quota_reset_date and now >= db_user.quota_reset_date:
   895	                db_user.tokens_used_this_month = 0
   896	                if now.month == 12:
   897	                    db_user.quota_reset_date = datetime(now.year + 1, 1, 1)
   898	                else:
   899	                    db_user.quota_reset_date = datetime(now.year, now.month + 1, 1)
   900	                db.commit()
   901	
   902	            if db_user.tokens_used_this_month >= db_user.quota_tokens_monthly:
   903	                yield _sse({"type": "error", "message": "Monthly token quota exceeded."})
   904	                return
   905	
   906	            attachments = []
   907	            if body.attachment_ids:
   908	                attachments = (
   909	                    db.query(ChatAttachment)
   910	                    .filter(ChatAttachment.id.in_(body.attachment_ids), ChatAttachment.chat_id == chat_id)
   911	                    .all()
   912	                )
   913	
   914	            stored_content = body.content
   915	            if attachments:
   916	                labels = {"image": "Image", "video": "Video", "document": "Document", "text": "File"}
   917	                notes = [f"[{labels.get(a.file_type, 'File')}: {a.original_filename}]" for a in attachments]
   918	                stored_content = "\n".join(notes) + "\n" + body.content
   919	
   920	            user_msg = Message(chat_id=chat_id, role="user", content=stored_content)
   921	            db.add(user_msg)
   922	            db.commit()
   923	            db.refresh(user_msg)
   924	
   925	            for att in attachments:
   926	                att.message_id = user_msg.id
   927	            if attachments:
   928	                db.commit()
   929	
   930	            kb_id = body.knowledge_base_id or chat.knowledge_base_id
   931	            rag_context = None
   932	            if kb_id:
   933	                try:
   934	                    rag_context = rag_service.query(kb_id, body.content, n_results=8)
   935	                except Exception:
   936	                    pass
   937	
   938	            system_prompt = build_full_prompt(rag_context)
   939	            messages = memory_service.build_messages(chat, db)
   940	
   941	            if attachments and messages and messages[-1]["role"] == "user":
   942	                content_blocks = attachment_service.build_claude_content_blocks(attachments)
   943	                content_blocks.append({"type": "text", "text": body.content})
   944	                messages[-1]["content"] = content_blocks
   945	
   946	            model_id = body.model or chat.model
   947	            max_tokens = body.max_tokens
   948	            thinking_config = None
   949	            if body.reasoning_budget > 0:
   950	                thinking_config = {"enabled": True, "budget_tokens": body.reasoning_budget}
   951	                max_tokens = max_tokens + body.reasoning_budget
   952	
   953	            full_text = ""
   954	            full_thinking = ""
   955	            input_tokens = 0
   956	            output_tokens = 0
   957	            current_block_type = "text"
   958	
   959	            async for event in bedrock_service.stream_response(
   960	                messages=messages, system_prompt=system_prompt,
   961	                model_id=model_id, max_tokens=min(max_tokens, 65536),
   962	                thinking_config=thinking_config,
   963	            ):
   964	                evt_type = event.get("type", "")
   965	
   966	                if evt_type == "message_start":
   967	                    usage = event.get("message", {}).get("usage", {})
   968	                    input_tokens = usage.get("input_tokens", 0)
   969	                elif evt_type == "content_block_start":
   970	                    blk = event.get("content_block", {})
   971	                    current_block_type = blk.get("type", "text")
   972	                    if current_block_type == "thinking":
   973	                        yield _sse({"type": "thinking_start"})
   974	                elif evt_type == "content_block_delta":
   975	                    delta = event.get("delta", {})
   976	                    dt = delta.get("type", "")
   977	                    if dt == "thinking_delta":
   978	                        t = delta.get("thinking", "")
   979	                        full_thinking += t
   980	                        yield _sse({"type": "thinking_delta", "content": t})
   981	                    elif dt == "text_delta":
   982	                        t = delta.get("text", "")
   983	                        full_text += t
   984	                        yield _sse({"type": "text_delta", "content": t})
   985	                elif evt_type == "content_block_stop":
   986	                    if current_block_type == "thinking":
   987	                        yield _sse({"type": "thinking_end"})
   988	                elif evt_type == "message_delta":
   989	                    usage = event.get("usage", {})
   990	                    output_tokens = usage.get("output_tokens", 0)
   991	
   992	            assistant_msg = Message(
   993	                chat_id=chat_id, role="assistant", content=full_text,
   994	                thinking_content=full_thinking or None,
   995	                input_tokens=input_tokens, output_tokens=output_tokens,
   996	            )
   997	            db.add(assistant_msg)
   998	            db_user.tokens_used_this_month += input_tokens + output_tokens
   999	            chat.model = model_id
  1000	            chat.max_tokens = body.max_tokens
  1001	            chat.reasoning_budget = body.reasoning_budget
  1002	            chat.knowledge_base_id = body.knowledge_base_id or None
  1003	            chat.updated_at = datetime.utcnow()
  1004	            db.commit()
  1005	
  1006	            msg_count = db.query(Message).filter(Message.chat_id == chat_id).count()
  1007	            if msg_count <= 2 and chat.title == "New Chat":
  1008	                try:
  1009	                    title = await _generate_title(body.content, full_text[:300])
  1010	                    chat.title = title[:120]
  1011	                    db.commit()
  1012	                    yield _sse({"type": "title_update", "title": chat.title})
  1013	                except Exception:
  1014	                    pass
  1015	
  1016	            yield _sse({"type": "usage", "input_tokens": input_tokens, "output_tokens": output_tokens})
  1017	            yield _sse({"type": "done", "message_id": assistant_msg.id})
  1018	
  1019	        except Exception as exc:
  1020	            yield _sse({"type": "error", "message": str(exc)})
  1021	        finally:
  1022	            db.close()
  1023	
  1024	    return StreamingResponse(generate(), media_type="text/event-stream")
  1025	
  1026	
  1027	async def _generate_title(user_msg, ai_msg):
  1028	    from backend.config import FAST_MODEL
  1029	    result = await bedrock_service.invoke_model_simple(
  1030	        model_id=FAST_MODEL,
  1031	        prompt=f"Generate a concise title (max 6 words) for this conversation:\nUser: {user_msg[:200]}\nAssistant: {ai_msg[:200]}\nRespond ONLY with the title.",
  1032	        max_tokens=30,
  1033	    )
  1034	    return result.strip().strip('"').strip("'")
  1035	
  1036	
  1037	def _sse(data):
  1038	    return f"data: {json.dumps(data)}\n\n"
  1039	
  1040	
  1041	def _chat_dict(c):
  1042	    return {
  1043	        "id": c.id, "title": c.title, "model": c.model,
  1044	        "knowledge_base_id": c.knowledge_base_id,
  1045	        "max_tokens": c.max_tokens or 4096,
  1046	        "reasoning_budget": c.reasoning_budget or 0,
  1047	        "created_at": str(c.created_at), "updated_at": str(c.updated_at),
  1048	    }
  1049	
  1050	
  1051	def _msg_dict(m):
  1052	    return {
  1053	        "id": m.id, "role": m.role, "content": m.content,
  1054	        "thinking_content": m.thinking_content,
  1055	        "input_tokens": m.input_tokens, "output_tokens": m.output_tokens,
  1056	        "created_at": str(m.created_at),
  1057	    }
  1058	
  1059	
  1060	def _att_brief(a):
  1061	    return {
  1062	        "id": a.id, "original_filename": a.original_filename,
  1063	        "mime_type": a.mime_type, "file_type": a.file_type,
  1064	        "file_size": a.file_size,
  1065	    }
  1066	ENDOFFILE
  1067	ok "backend/routes/chat_routes.py"
  1068	
  1069	# ────────────────────────────────────────
  1070	# backend/services/memory_service.py
  1071	# ────────────────────────────────────────
  1072	step "backend/services/memory_service.py"
  1073	cat > backend/services/memory_service.py << 'ENDOFFILE'
  1074	"""
  1075	Build the messages list for the Bedrock/Anthropic API from chat history.
  1076	"""
  1077	
  1078	from sqlalchemy.orm import Session
  1079	from backend.models import Chat, Message
  1080	
  1081	MAX_CONTEXT_CHARS = 400_000
  1082	MAX_MESSAGES = 80
  1083	
  1084	
  1085	def build_messages(chat: Chat, db: Session) -> list[dict]:
  1086	    rows: list[Message] = (
  1087	        db.query(Message)
  1088	        .filter(Message.chat_id == chat.id)
  1089	        .order_by(Message.created_at.desc())
  1090	        .limit(MAX_MESSAGES)
  1091	        .all()
  1092	    )
  1093	    rows.reverse()
  1094	
  1095	    if not rows:
  1096	        return []
  1097	
  1098	    total_chars = sum(len(m.content or "") for m in rows)
  1099	    idx = 0
  1100	    while total_chars > MAX_CONTEXT_CHARS and idx < len(rows) - 2:
  1101	        total_chars -= len(rows[idx].content or "")
  1102	        idx += 1
  1103	
  1104	    trimmed = rows[idx:]
  1105	
  1106	    while trimmed and trimmed[0].role != "user":
  1107	        trimmed = trimmed[1:]
  1108	
  1109	    result: list[dict] = []
  1110	    for m in trimmed:
  1111	        content = m.content or ""
  1112	        if not content.strip():
  1113	            continue
  1114	        role = m.role
  1115	        if role not in ("user", "assistant"):
  1116	            continue
  1117	        if result and result[-1]["role"] == role:
  1118	            result[-1]["content"] += "\n" + content
  1119	        else:
  1120	            result.append({"role": role, "content": content})
  1121	
  1122	    return result
  1123	ENDOFFILE
  1124	ok "backend/services/memory_service.py"
  1125	
  1126	# ────────────────────────────────────────
  1127	# frontend/src/api.js
  1128	# ────────────────────────────────────────
  1129	step "frontend/src/api.js"
  1130	cat > frontend/src/api.js << 'ENDOFFILE'
  1131	const BASE = "/api";
  1132	
  1133	function headers(token) {
  1134	  const h = { "Content-Type": "application/json" };
  1135	  if (token) h["Authorization"] = `Bearer ${token}`;
  1136	  return h;
  1137	}
  1138	
  1139	function authHeader(token) {
  1140	  return token ? { Authorization: `Bearer ${token}` } : {};
  1141	}
  1142	
  1143	async function request(method, path, token, body) {
  1144	  const opts = { method, headers: headers(token) };
  1145	  if (body) opts.body = JSON.stringify(body);
  1146	  const res = await fetch(`${BASE}${path}`, opts);
  1147	  if (!res.ok) {
  1148	    const err = await res.json().catch(() => ({ detail: res.statusText }));
  1149	    throw new Error(err.detail || err.message || "Request failed");
  1150	  }
  1151	  return res.json();
  1152	}
  1153	
  1154	export const login = (username, password) =>
  1155	  request("POST", "/auth/login", null, { username, password });
  1156	
  1157	export const register = (username, email, password) =>
  1158	  request("POST", "/auth/register", null, { username, email, password });
  1159	
  1160	export const getMe = (token) => request("GET", "/auth/me", token);
  1161	
  1162	export const listChats = (token) => request("GET", "/chats", token);
  1163	
  1164	export const createChat = (token, data = {}) => request("POST", "/chats", token, data);
  1165	
  1166	export const updateChat = (token, chatId, data) =>
  1167	  request("PUT", `/chats/${chatId}`, token, data);
  1168	
  1169	export const renameChat = (token, chatId, title) =>
  1170	  updateChat(token, chatId, { title });
  1171	
  1172	export const deleteChat = (token, chatId) =>
  1173	  request("DELETE", `/chats/${chatId}`, token);
  1174	
  1175	export const getMessages = (token, chatId) =>
  1176	  request("GET", `/chats/${chatId}/messages`, token);
  1177	
  1178	export async function* streamMessage(token, chatId, body, signal) {
  1179	  const res = await fetch(`${BASE}/chats/${chatId}/messages`, {
  1180	    method: "POST", headers: headers(token),
  1181	    body: JSON.stringify(body), signal,
  1182	  });
  1183	  if (!res.ok) {
  1184	    const err = await res.json().catch(() => ({ detail: res.statusText }));
  1185	    throw new Error(err.detail || "Stream failed");
  1186	  }
  1187	  const reader = res.body.getReader();
  1188	  const decoder = new TextDecoder();
  1189	  let buffer = "";
  1190	  while (true) {
  1191	    const { done, value } = await reader.read();
  1192	    if (done) break;
  1193	    buffer += decoder.decode(value, { stream: true });
  1194	    const parts = buffer.split("\n\n");
  1195	    buffer = parts.pop() || "";
  1196	    for (const part of parts) {
  1197	      const line = part.trim();
  1198	      if (line.startsWith("data: ")) {
  1199	        try { yield JSON.parse(line.slice(6)); } catch { }
  1200	      }
  1201	    }
  1202	  }
  1203	  if (buffer.trim().startsWith("data: ")) {
  1204	    try { yield JSON.parse(buffer.trim().slice(6)); } catch { }
  1205	  }
  1206	}
  1207	
  1208	export async function uploadAttachments(token, chatId, files) {
  1209	  const form = new FormData();
  1210	  for (const file of files) form.append("files", file);
  1211	  const res = await fetch(`${BASE}/chats/${chatId}/attachments`, {
  1212	    method: "POST", headers: authHeader(token), body: form,
  1213	  });
  1214	  if (!res.ok) {
  1215	    const err = await res.json().catch(() => ({}));
  1216	    throw new Error(err.detail || "Upload failed");
  1217	  }
  1218	  return res.json();
  1219	}
  1220	
  1221	export function getAttachmentUrl(attachmentId) {
  1222	  return `${BASE}/attachments/${attachmentId}/file`;
  1223	}
  1224	
  1225	export const deleteAttachment = (token, attachmentId) =>
  1226	  request("DELETE", `/attachments/${attachmentId}`, token);
  1227	
  1228	export const listKnowledgeBases = (token) => request("GET", "/knowledge", token);
  1229	
  1230	export const createKnowledgeBase = (token, name, description = "") =>
  1231	  request("POST", "/knowledge", token, { name, description });
  1232	
  1233	export const getKnowledgeBase = (token, kbId) =>
  1234	  request("GET", `/knowledge/${kbId}`, token);
  1235	
  1236	export const deleteKnowledgeBase = (token, kbId) =>
  1237	  request("DELETE", `/knowledge/${kbId}`, token);
  1238	
  1239	export async function uploadDocuments(token, kbId, files) {
  1240	  const form = new FormData();
  1241	  for (const file of files) form.append("files", file);
  1242	  const res = await fetch(`${BASE}/knowledge/${kbId}/upload`, {
  1243	    method: "POST", headers: authHeader(token), body: form,
  1244	  });
  1245	  if (!res.ok) {
  1246	    const err = await res.json().catch(() => ({}));
  1247	    throw new Error(err.detail || "Upload failed");
  1248	  }
  1249	  return res.json();
  1250	}
  1251	
  1252	export const uploadDocument = (token, kbId, file) =>
  1253	  uploadDocuments(token, kbId, [file]);
  1254	
  1255	export const adminStats = (token) => request("GET", "/admin/stats", token);
  1256	
  1257	export const adminListUsers = (token) => request("GET", "/admin/users", token);
  1258	
  1259	export const adminCreateUser = (token, data) =>
  1260	  request("POST", "/admin/users", token, data);
  1261	
  1262	export const adminUpdateUser = (token, userId, data) =>
  1263	  request("PUT", `/admin/users/${userId}`, token, data);
  1264	
  1265	export const adminDeleteUser = (token, userId) =>
  1266	  request("DELETE", `/admin/users/${userId}`, token);
  1267	
  1268	export const adminListChats = (token) => request("GET", "/admin/chats", token);
  1269	
  1270	export async function downloadZip(token, markdown) {
  1271	  const res = await fetch(`${BASE}/files/download-zip`, {
  1272	    method: "POST", headers: headers(token),
  1273	    body: JSON.stringify({ markdown }),
  1274	  });
  1275	  if (!res.ok) throw new Error("Download failed");
  1276	  const ct = res.headers.get("content-type") || "";
  1277	  if (ct.includes("application/zip")) {
  1278	    const blob = await res.blob();
  1279	    const url = URL.createObjectURL(blob);
  1280	    const a = document.createElement("a");
  1281	    a.href = url;
  1282	    a.download = "son-of-anton-code.zip";
  1283	    a.click();
  1284	    URL.revokeObjectURL(url);
  1285	  } else {
  1286	    const data = await res.json();
  1287	    if (data.error) throw new Error(data.error);
  1288	  }
  1289	}
  1290	ENDOFFILE
  1291	ok "frontend/src/api.js"
  1292	
  1293	# ────────────────────────────────────────
  1294	# frontend/src/components/MessageBubble.jsx
  1295	# ────────────────────────────────────────
  1296	step "frontend/src/components/MessageBubble.jsx"
  1297	cat > frontend/src/components/MessageBubble.jsx << 'ENDOFFILE'
  1298	import React, { useState } from "react";
  1299	import ReactMarkdown from "react-markdown";
  1300	import remarkGfm from "remark-gfm";
  1301	import CodeBlock from "./CodeBlock";
  1302	import { getAttachmentUrl } from "../api";
  1303	import {
  1304	  User, Flame, ChevronDown, ChevronRight, Brain, Copy, Check,
  1305	  Image, Film, FileText, ExternalLink,
  1306	} from "lucide-react";
  1307	
  1308	const FILE_TYPE_ICONS = {
  1309	  image: Image, video: Film, document: FileText, text: FileText,
  1310	};
  1311	
  1312	const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking, token }) {
  1313	  const { role, content, thinking_content, input_tokens, output_tokens, attachments } = message;
  1314	  const isUser = role === "user";
  1315	  const [showThinking, setShowThinking] = useState(false);
  1316	  const [copied, setCopied] = useState(false);
  1317	  const [expandedImage, setExpandedImage] = useState(null);
  1318	
  1319	  function handleCopy() {
  1320	    navigator.clipboard.writeText(content || "");
  1321	    setCopied(true);
  1322	    setTimeout(() => setCopied(false), 2000);
  1323	  }
  1324	
  1325	  const hasAttachments = attachments && attachments.length > 0;
  1326	
  1327	  return (
  1328	    <div className={`flex gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}>
  1329	      {!isUser && (
  1330	        <div className="shrink-0 mt-1">
  1331	          <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/10">
  1332	            <Flame size={16} className="text-white" />
  1333	          </div>
  1334	        </div>
  1335	      )}
  1336	
  1337	      <div className={`max-w-[80%] ${isUser ? "order-first" : ""}`}>
  1338	        {thinking_content && (
  1339	          <div className="mb-2">
  1340	            <button onClick={() => setShowThinking(!showThinking)}
  1341	              className="flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1">
  1342	              <Brain size={12} />
  1343	              {showThinking ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
  1344	              {isThinking ? <span className="thinking-pulse">Reasoning…</span> : <span>View reasoning</span>}
  1345	            </button>
  1346	            {(showThinking || isThinking) && (
  1347	              <div className="bg-purple-500/5 border border-purple-500/20 rounded-lg p-3 text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-60 overflow-y-auto">
  1348	                {thinking_content}
  1349	                {isThinking && <span className="inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse" />}
  1350	              </div>
  1351	            )}
  1352	          </div>
  1353	        )}
  1354	
  1355	        {hasAttachments && (
  1356	          <div className="mb-2 flex flex-wrap gap-2">
  1357	            {attachments.map((att) => {
  1358	              const Icon = FILE_TYPE_ICONS[att.file_type] || FileText;
  1359	              const url = getAttachmentUrl(att.id);
  1360	              if (att.file_type === "image") {
  1361	                return (
  1362	                  <div key={att.id} className="relative group">
  1363	                    <img src={`${url}?token=${token}`} alt={att.original_filename}
  1364	                      className="max-w-[240px] max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition"
  1365	                      onClick={() => setExpandedImage(expandedImage === att.id ? null : att.id)}
  1366	                      onError={(e) => { e.target.style.display = "none"; }} />
  1367	                    {expandedImage === att.id && (
  1368	                      <div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-8 cursor-pointer"
  1369	                        onClick={() => setExpandedImage(null)}>
  1370	                        <img src={`${url}?token=${token}`} alt={att.original_filename}
  1371	                          className="max-w-full max-h-full object-contain rounded-lg" />
  1372	                      </div>
  1373	                    )}
  1374	                    <div className="absolute bottom-1 left-1 bg-black/60 text-[9px] text-white px-1.5 py-0.5 rounded">
  1375	                      {att.original_filename}
  1376	                    </div>
  1377	                  </div>
  1378	                );
  1379	              }
  1380	              return (
  1381	                <a key={att.id} href={`${url}?token=${token}`} target="_blank" rel="noopener noreferrer"
  1382	                  className="flex items-center gap-2 bg-anton-card border border-anton-border rounded-lg px-3 py-2 hover:border-anton-accent transition group">
  1383	                  <Icon size={16} className="shrink-0 text-blue-400" />
  1384	                  <div className="min-w-0">
  1385	                    <div className="text-xs text-white truncate max-w-[160px]">{att.original_filename}</div>
  1386	                    <div className="text-[10px] text-anton-muted">{(att.file_size / 1024).toFixed(0)}KB</div>
  1387	                  </div>
  1388	                  <ExternalLink size={12} className="text-anton-muted group-hover:text-anton-accent shrink-0" />
  1389	                </a>
  1390	              );
  1391	            })}
  1392	          </div>
  1393	        )}
  1394	
  1395	        <div className={`rounded-2xl px-4 py-3 ${
  1396	          isUser ? "bg-anton-accent text-white rounded-br-md" : "bg-anton-card border border-anton-border rounded-bl-md"
  1397	        }`}>
  1398	          {isUser ? (
  1399	            <div className="text-sm whitespace-pre-wrap">{_stripPrefixes(content)}</div>
  1400	          ) : (
  1401	            <div className="prose-anton text-sm">
  1402	              <ReactMarkdown remarkPlugins={[remarkGfm]} components={{
  1403	                code({ node, inline, className, children, ...props }) {
  1404	                  const match = /language-(\S+)/.exec(className || "");
  1405	                  const rawLang = match?.[1] || "";
  1406	                  if (inline) return <code className={className} {...props}>{children}</code>;
  1407	                  let lang = rawLang, filename = null;
  1408	                  if (rawLang.includes(":")) {
  1409	                    const idx = rawLang.indexOf(":");
  1410	                    lang = rawLang.slice(0, idx);
  1411	                    filename = rawLang.slice(idx + 1);
  1412	                  }
  1413	                  return <CodeBlock language={lang} filename={filename} code={String(children).replace(/\n$/, "")} />;
  1414	                },
  1415	                pre({ children }) { return <>{children}</>; },
  1416	              }}>
  1417	                {content || ""}
  1418	              </ReactMarkdown>
  1419	              {isStreaming && !isThinking && (
  1420	                <span className="inline-block w-1.5 h-4 bg-anton-accent ml-0.5 animate-pulse" />
  1421	              )}
  1422	            </div>
  1423	          )}
  1424	        </div>
  1425	
  1426	        {!isUser && !isStreaming && content && (
  1427	          <div className="flex items-center gap-3 mt-1.5 px-1">
  1428	            <button onClick={handleCopy} className="flex items-center gap-1 text-[11px] text-anton-muted hover:text-white transition">
  1429	              {copied ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />}
  1430	              {copied ? "Copied" : "Copy"}
  1431	            </button>
  1432	            {(input_tokens > 0 || output_tokens > 0) && (
  1433	              <span className="text-[11px] text-anton-muted">
  1434	                {input_tokens?.toLocaleString()}↓ / {output_tokens?.toLocaleString()}↑ tokens
  1435	              </span>
  1436	            )}
  1437	          </div>
  1438	        )}
  1439	      </div>
  1440	
  1441	      {isUser && (
  1442	        <div className="shrink-0 mt-1">
  1443	          <div className="w-8 h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center">
  1444	            <User size={16} className="text-anton-muted" />
  1445	          </div>
  1446	        </div>
  1447	      )}
  1448	    </div>
  1449	  );
  1450	});
  1451	
  1452	function _stripPrefixes(text) {
  1453	  if (!text) return "";
  1454	  return text.replace(/^\[(?:Image|Video|Document|File):\s[^\]]*\]\n?/gm, "").trim();
  1455	}
  1456	
  1457	export default MessageBubble;
  1458	ENDOFFILE
  1459	ok "frontend/src/components/MessageBubble.jsx"
  1460	
  1461	# ────────────────────────────────────────
  1462	# frontend/src/components/ChatView.jsx
  1463	# ────────────────────────────────────────
  1464	step "frontend/src/components/ChatView.jsx"
  1465	cat > frontend/src/components/ChatView.jsx << 'ENDOFFILE'
  1466	import React, { useState, useEffect, useRef, useCallback } from "react";
  1467	import { useApp } from "../store";
  1468	import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments } from "../api";
  1469	import * as streamManager from "../streamManager";
  1470	import MessageBubble from "./MessageBubble";
  1471	import { Send, Square, Settings2, X, Brain, BookOpen, Paperclip, Image, FileText, Film, Loader2 } from "lucide-react";
  1472	
  1473	const MODELS = [
  1474	  { id: "eu.anthropic.claude-opus-4-6-v1", label: "Claude Opus 4.6 (Primary)" },
  1475	  { id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Claude Haiku 4.5 (Fast)" },
  1476	];
  1477	
  1478	function classifyFile(file) {
  1479	  const ext = (file.name || "").split(".").pop().toLowerCase();
  1480	  const mime = file.type || "";
  1481	  if (mime.startsWith("image/") || ["jpg","jpeg","png","gif","webp","bmp"].includes(ext)) return "image";
  1482	  if (mime.startsWith("video/") || ["mp4","mov","avi","mkv","webm"].includes(ext)) return "video";
  1483	  if (mime === "application/pdf" || ext === "pdf") return "document";
  1484	  return "text";
  1485	}
  1486	
  1487	export default function ChatView({ chatId }) {
  1488	  const { state, dispatch } = useApp();
  1489	  const currentChat = state.chats.find((c) => c.id === chatId);
  1490	  const messages = state.chatMessages[chatId] || [];
  1491	  const isStreamingGlobal = !!state.activeStreams[chatId];
  1492	
  1493	  const [input, setInput] = useState("");
  1494	  const [showSettings, setShowSettings] = useState(false);
  1495	  const [model, setModel] = useState(currentChat?.model || MODELS[0].id);
  1496	  const [maxTokens, setMaxTokens] = useState(currentChat?.max_tokens || 4096);
  1497	  const [reasoningBudget, setReasoningBudget] = useState(currentChat?.reasoning_budget ?? 0);
  1498	  const [selectedKbId, setSelectedKbId] = useState(currentChat?.knowledge_base_id || null);
  1499	  const [kbs, setKbs] = useState([]);
  1500	  const [pendingFiles, setPendingFiles] = useState([]);
  1501	  const [uploading, setUploading] = useState(false);
  1502	  const [streamData, setStreamData] = useState(streamManager.getStreamData(chatId));
  1503	
  1504	  const scrollRef = useRef(null);
  1505	  const inputRef = useRef(null);
  1506	  const fileRef = useRef(null);
  1507	  const autoScroll = useRef(true);
  1508	  const rafRef = useRef(null);
  1509	
  1510	  useEffect(() => {
  1511	    setStreamData(streamManager.getStreamData(chatId));
  1512	    return streamManager.subscribe(chatId, () => setStreamData(streamManager.getStreamData(chatId)));
  1513	  }, [chatId]);
  1514	
  1515	  function onScroll() {
  1516	    const el = scrollRef.current;
  1517	    if (!el) return;
  1518	    autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200;
  1519	  }
  1520	
  1521	  const scrollBottom = useCallback(() => {
  1522	    if (!autoScroll.current || rafRef.current) return;
  1523	    rafRef.current = requestAnimationFrame(() => {
  1524	      if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
  1525	      rafRef.current = null;
  1526	    });
  1527	  }, []);
  1528	
  1529	  useEffect(() => {
  1530	    (async () => {
  1531	      try {
  1532	        const [msgs, kbData] = await Promise.all([getMessages(state.token, chatId), listKnowledgeBases(state.token)]);
  1533	        dispatch({ type: "SET_MESSAGES", chatId, messages: msgs });
  1534	        setKbs(kbData);
  1535	      } catch {}
  1536	    })();
  1537	  }, [chatId, state.token, dispatch]);
  1538	
  1539	  useEffect(scrollBottom, [messages, streamData.text, streamData.thinking, scrollBottom]);
  1540	  useEffect(() => { inputRef.current?.focus(); }, [chatId]);
  1541	
  1542	  async function saveSettings() {
  1543	    try {
  1544	      await updateChat(state.token, chatId, { model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId || "" });
  1545	      dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId } });
  1546	    } catch {}
  1547	  }
  1548	
  1549	  function toggleSettings() {
  1550	    if (showSettings) saveSettings();
  1551	    setShowSettings(!showSettings);
  1552	  }
  1553	
  1554	  function handleFileSelect(e) {
  1555	    const files = Array.from(e.target.files || []);
  1556	    setPendingFiles((prev) => [...prev, ...files.map((f) => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]);
  1557	    e.target.value = "";
  1558	  }
  1559	
  1560	  function removePending(i) {
  1561	    setPendingFiles((prev) => { if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview); return prev.filter((_, j) => j !== i); });
  1562	  }
  1563	
  1564	  async function handleSend() {
  1565	    const content = input.trim();
  1566	    if ((!content && !pendingFiles.length) || isStreamingGlobal) return;
  1567	    const text = content || "Please analyze the attached file(s).";
  1568	
  1569	    let attIds = [], uploaded = [];
  1570	    if (pendingFiles.length) {
  1571	      setUploading(true);
  1572	      try {
  1573	        const res = await uploadAttachments(state.token, chatId, pendingFiles.map((p) => p.file));
  1574	        uploaded = (res.attachments || []).filter((a) => !a.error);
  1575	        attIds = uploaded.map((a) => a.id);
  1576	      } catch (err) { console.error(err); setUploading(false); return; }
  1577	      setUploading(false);
  1578	    }
  1579	
  1580	    dispatch({ type: "ADD_MESSAGE", chatId, message: { id: `tmp-${Date.now()}`, role: "user", content: text, created_at: new Date().toISOString(), attachments: uploaded } });
  1581	    setInput("");
  1582	    pendingFiles.forEach((p) => { if (p.preview) URL.revokeObjectURL(p.preview); });
  1583	    setPendingFiles([]);
  1584	    autoScroll.current = true;
  1585	
  1586	    dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId } });
  1587	    streamManager.startStream({ token: state.token, chatId, body: { content: text, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, attachment_ids: attIds } });
  1588	  }
  1589	
  1590	  function handleKeyDown(e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }
  1591	
  1592	  function handlePaste(e) {
  1593	    const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith("image/"));
  1594	    if (!imgs.length) return;
  1595	    e.preventDefault();
  1596	    setPendingFiles((prev) => [...prev, ...imgs.map((i) => { const f = i.getAsFile(); return { file: f, type: "image", preview: URL.createObjectURL(f) }; })]);
  1597	  }
  1598	
  1599	  function handleDrop(e) {
  1600	    e.preventDefault();
  1601	    const files = Array.from(e.dataTransfer?.files || []);
  1602	    if (files.length) setPendingFiles((prev) => [...prev, ...files.map((f) => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]);
  1603	  }
  1604	
  1605	  const streaming = streamData.streaming;
  1606	
  1607	  return (
  1608	    <div className="flex-1 flex flex-col min-h-0" onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
  1609	      <div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
  1610	        {messages.map((m) => <MessageBubble key={m.id} message={m} token={state.token} />)}
  1611	        {streaming && (streamData.thinking || streamData.text) && (
  1612	          <MessageBubble message={{ id: "streaming", role: "assistant", content: streamData.text, thinking_content: streamData.thinking || null, attachments: [] }} isStreaming isThinking={streamData.isThinking} token={state.token} />
  1613	        )}
  1614	        {streaming && !streamData.text && !streamData.thinking && (
  1615	          <div className="flex items-center gap-2 px-4 py-3 animate-fade-in">
  1616	            <div className="flex gap-1">
  1617	              <span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
  1618	              <span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
  1619	              <span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
  1620	            </div>
  1621	            <span className="text-anton-muted text-sm">Son of Anton is thinking…</span>
  1622	          </div>
  1623	        )}
  1624	      </div>
  1625	
  1626	      <div className="border-t border-anton-border bg-anton-surface p-4">
  1627	        {showSettings && (
  1628	          <div className="mb-3 bg-anton-card border border-anton-border rounded-xl p-4 space-y-4 animate-fade-in">
  1629	            <div className="flex items-center justify-between">
  1630	              <h3 className="text-sm font-semibold text-white flex items-center gap-1.5"><Settings2 size={14} className="text-anton-accent" /> Settings</h3>
  1631	              <button onClick={toggleSettings} className="text-anton-muted hover:text-white"><X size={14} /></button>
  1632	            </div>
  1633	            <div>
  1634	              <label className="text-xs text-anton-muted mb-1 block">Model</label>
  1635	              <select value={model} onChange={(e) => setModel(e.target.value)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent">
  1636	                {MODELS.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
  1637	              </select>
  1638	            </div>
  1639	            <div>
  1640	              <div className="flex justify-between text-xs mb-1"><span className="text-anton-muted">Max Tokens</span><span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}</span></div>
  1641	              <input type="range" min={256} max={65536} step={256} value={maxTokens} onChange={(e) => setMaxTokens(Number(e.target.value))} />
  1642	            </div>
  1643	            <div>
  1644	              <div className="flex justify-between text-xs mb-1"><span className="text-anton-muted flex items-center gap-1"><Brain size={12} className="text-purple-400" /> Reasoning</span><span className="text-purple-400 font-mono">{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}</span></div>
  1645	              <input type="range" min={0} max={32000} step={500} value={reasoningBudget} onChange={(e) => setReasoningBudget(Number(e.target.value))} />
  1646	            </div>
  1647	            <div>
  1648	              <label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><BookOpen size={12} /> Knowledge Base</label>
  1649	              <select value={selectedKbId || ""} onChange={(e) => setSelectedKbId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent">
  1650	                <option value="">None</option>
  1651	                {kbs.map((kb) => <option key={kb.id} value={kb.id}>{kb.name} ({kb.document_count} docs)</option>)}
  1652	              </select>
  1653	            </div>
  1654	          </div>
  1655	        )}
  1656	
  1657	        {pendingFiles.length > 0 && (
  1658	          <div className="mb-3 flex flex-wrap gap-2 animate-fade-in">
  1659	            {pendingFiles.map((pf, i) => (
  1660	              <div key={i} className="relative group bg-anton-card border border-anton-border rounded-lg overflow-hidden">
  1661	                {pf.type === "image" && pf.preview ? (
  1662	                  <img src={pf.preview} alt="" className="w-16 h-16 object-cover" />
  1663	                ) : (
  1664	                  <div className="w-16 h-16 flex flex-col items-center justify-center px-1">
  1665	                    <FileText size={20} className="text-anton-muted mb-1" />
  1666	                    <span className="text-[9px] text-anton-muted text-center truncate w-full">{pf.file.name.slice(0, 10)}</span>
  1667	                  </div>
  1668	                )}
  1669	                <button onClick={() => removePending(i)} className="absolute -top-1 -right-1 w-5 h-5 bg-anton-danger rounded-full flex items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-opacity"><X size={10} /></button>
  1670	                <div className="absolute bottom-0 left-0 right-0 bg-black/60 text-[8px] text-white text-center py-0.5">{(pf.file.size / 1024).toFixed(0)}KB</div>
  1671	              </div>
  1672	            ))}
  1673	          </div>
  1674	        )}
  1675	
  1676	        <div className="flex items-end gap-2">
  1677	          <button onClick={toggleSettings} className={`p-2.5 rounded-xl transition shrink-0 ${showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"}`}><Settings2 size={18} /></button>
  1678	          <button onClick={() => fileRef.current?.click()} className={`p-2.5 rounded-xl transition shrink-0 ${pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`} title="Attach files"><Paperclip size={18} /></button>
  1679	          <input ref={fileRef} type="file" multiple className="hidden" accept="image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.dart,.vue,.svelte,.log" onChange={handleFileSelect} />
  1680	          <div className="flex-1 relative">
  1681	            <textarea ref={inputRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste}
  1682	              placeholder={pendingFiles.length ? "Add a message or send to analyze files…" : "Ask Son of Anton anything…"}
  1683	              rows={1} style={{ maxHeight: "200px" }}
  1684	              className="w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 text-white text-sm resize-none focus:outline-none focus:border-anton-accent transition"
  1685	              onInput={(e) => { e.target.style.height = "auto"; e.target.style.height = Math.min(e.target.scrollHeight, 200) + "px"; }} />
  1686	          </div>
  1687	          {streaming ? (
  1688	            <button onClick={() => streamManager.abortStream(chatId)} className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0"><Square size={18} /></button>
  1689	          ) : (
  1690	            <button onClick={handleSend} disabled={(!input.trim() && !pendingFiles.length) || isStreamingGlobal || uploading}
  1691	              className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 disabled:opacity-30 disabled:cursor-not-allowed">
  1692	              {uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
  1693	            </button>
  1694	          )}
  1695	        </div>
  1696	
  1697	        <div className="flex items-center gap-3 mt-2 text-[11px] text-anton-muted">
  1698	          <span>{MODELS.find((m) => m.id === model)?.label}</span>
  1699	          <span>•</span><span>{maxTokens.toLocaleString()} tokens</span>
  1700	          {reasoningBudget > 0 && <><span>•</span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>}
  1701	          {selectedKbId && <><span>•</span><span className="text-green-400">📚 RAG</span></>}
  1702	          {pendingFiles.length > 0 && <><span>•</span><span className="text-blue-400">📎 {pendingFiles.length} file{pendingFiles.length !== 1 ? "s" : ""}</span></>}
  1703	          {messages.some((m) => m.role === "assistant") && (
  1704	            <button onClick={async () => { const all = messages.filter((m) => m.role === "assistant").map((m) => m.content).join("\n\n---\n\n"); if (all) try { await downloadZip(state.token, all); } catch {} }}
  1705	              className="ml-auto hover:text-anton-accent transition">⬇ Download code</button>
  1706	          )}
  1707	        </div>
  1708	      </div>
  1709	    </div>
  1710	  );
  1711	}
  1712	ENDOFFILE
  1713	ok "frontend/src/components/ChatView.jsx"
  1714	
  1715	# ────────────────────────────────────────
  1716	# frontend/src/streamManager.js
  1717	# ────────────────────────────────────────
  1718	step "frontend/src/streamManager.js"
  1719	cat > frontend/src/streamManager.js << 'ENDOFFILE'
  1720	import { streamMessage } from "./api";
  1721	
  1722	const _streams = new Map();
  1723	const _listeners = new Map();
  1724	let _dispatch = null;
  1725	
  1726	export function setDispatch(dispatch) { _dispatch = dispatch; }
  1727	
  1728	export function getStreamData(chatId) {
  1729	  const s = _streams.get(chatId);
  1730	  if (!s) return { streaming: false, text: "", thinking: "", isThinking: false };
  1731	  return { streaming: true, text: s.text, thinking: s.thinking, isThinking: s.isThinking };
  1732	}
  1733	
  1734	export function isStreaming(chatId) { return _streams.has(chatId); }
  1735	
  1736	export function subscribe(chatId, cb) {
  1737	  if (!_listeners.has(chatId)) _listeners.set(chatId, new Set());
  1738	  _listeners.get(chatId).add(cb);
  1739	  return () => { const s = _listeners.get(chatId); if (s) { s.delete(cb); if (!s.size) _listeners.delete(chatId); } };
  1740	}
  1741	
  1742	function _notify(id) { const s = _listeners.get(id); if (s) s.forEach((cb) => cb()); }
  1743	
  1744	export function abortStream(chatId) {
  1745	  const s = _streams.get(chatId);
  1746	  if (s) { s.abortController.abort(); _streams.delete(chatId); _notify(chatId); if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: false }); }
  1747	}
  1748	
  1749	export function startStream({ token, chatId, body }) {
  1750	  if (_streams.has(chatId)) return;
  1751	  const ac = new AbortController();
  1752	  _streams.set(chatId, { text: "", thinking: "", isThinking: false, abortController: ac });
  1753	  if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: true });
  1754	  _notify(chatId);
  1755	
  1756	  (async () => {
  1757	    const s = _streams.get(chatId);
  1758	    if (!s) return;
  1759	    let usage = {}, msgId = "";
  1760	    try {
  1761	      for await (const evt of streamMessage(token, chatId, body, ac.signal)) {
  1762	        if (ac.signal.aborted || !_streams.has(chatId)) break;
  1763	        switch (evt.type) {
  1764	          case "thinking_start": s.isThinking = true; _notify(chatId); break;
  1765	          case "thinking_delta": s.thinking += evt.content; _notify(chatId); break;
  1766	          case "thinking_end": s.isThinking = false; _notify(chatId); break;
  1767	          case "text_delta": s.text += evt.content; _notify(chatId); break;
  1768	          case "usage": usage = { input_tokens: evt.input_tokens, output_tokens: evt.output_tokens }; break;
  1769	          case "title_update": if (_dispatch) _dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, title: evt.title } }); break;
  1770	          case "done": msgId = evt.message_id; break;
  1771	          case "error": s.text += `\n\n**Error:** ${evt.message}`; _notify(chatId); break;
  1772	        }
  1773	      }
  1774	      if (!ac.signal.aborted && _dispatch) {
  1775	        _dispatch({ type: "ADD_MESSAGE", chatId, message: {
  1776	          id: msgId || `gen-${Date.now()}`, role: "assistant", content: s.text,
  1777	          thinking_content: s.thinking || null, input_tokens: usage.input_tokens || 0,
  1778	          output_tokens: usage.output_tokens || 0, created_at: new Date().toISOString(), attachments: [],
  1779	        }});
  1780	      }
  1781	    } catch (err) {
  1782	      if (!ac.signal.aborted && _dispatch) {
  1783	        _dispatch({ type: "ADD_MESSAGE", chatId, message: {
  1784	          id: `err-${Date.now()}`, role: "assistant", content: `**Error:** ${err.message}`,
  1785	          created_at: new Date().toISOString(), attachments: [],
  1786	        }});
  1787	      }
  1788	    } finally {
  1789	      _streams.delete(chatId); _notify(chatId);
  1790	      if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: false });
  1791	    }
  1792	  })();
  1793	}
  1794	ENDOFFILE
  1795	ok "frontend/src/streamManager.js"
  1796	
  1797	# ────────────────────────────────────────
  1798	# Done
  1799	# ────────────────────────────────────────
  1800	echo ""
  1801	echo -e "${GREEN}${BOLD}═══════════════════════════════════════════════════${NC}"
  1802	echo -e "${GREEN}${BOLD}  All 12 files written successfully!${NC}"
  1803	echo -e "${GREEN}${BOLD}═══════════════════════════════════════════════════${NC}"
  1804	echo ""
  1805	echo -e "  Now run: ${CYAN}zsh fix-and-deploy.sh${NC}"
  1806	echo ""│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [005]: atchfiles.sh


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [006/69]: backend/auth.py
│ LANGUAGE: python | LINES: 219 | SIZE: 8249 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	Authentication helpers + permission system — Son of Anton v4.2.0
     3	"""
     4	
     5	from datetime import datetime, timedelta
     6	
     7	import jwt
     8	from passlib.context import CryptContext
     9	from fastapi import Depends, HTTPException, status
    10	from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
    11	from sqlalchemy.orm import Session
    12	
    13	from backend.database import get_db
    14	from backend.config import JWT_SECRET, DEFAULT_PERMISSIONS, SUPERADMIN_PERMISSIONS, PERMISSION_FIELDS
    15	
    16	pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
    17	bearer_scheme = HTTPBearer()
    18	
    19	
    20	def hash_password(password: str) -> str:
    21	    return pwd_ctx.hash(password)
    22	
    23	
    24	def verify_password(plain: str, hashed: str) -> bool:
    25	    return pwd_ctx.verify(plain, hashed)
    26	
    27	
    28	def create_token(user_id: str, role: str) -> str:
    29	    payload = {
    30	        "sub": user_id,
    31	        "role": role,
    32	        "exp": datetime.utcnow() + timedelta(days=30),
    33	    }
    34	    return jwt.encode(payload, JWT_SECRET, algorithm="HS256")
    35	
    36	
    37	def decode_token(token: str) -> dict:
    38	    try:
    39	        return jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
    40	    except jwt.ExpiredSignatureError:
    41	        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Token expired")
    42	    except jwt.InvalidTokenError:
    43	        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid token")
    44	
    45	
    46	def get_current_user(
    47	    credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
    48	    db: Session = Depends(get_db),
    49	):
    50	    from backend.models import User
    51	    payload = decode_token(credentials.credentials)
    52	    user = db.query(User).filter(User.id == payload["sub"]).first()
    53	    if not user:
    54	        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User not found")
    55	    if not user.is_active:
    56	        raise HTTPException(status.HTTP_403_FORBIDDEN, "Account disabled")
    57	    return user
    58	
    59	
    60	def require_admin(
    61	    credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
    62	    db: Session = Depends(get_db),
    63	):
    64	    from backend.models import User
    65	    payload = decode_token(credentials.credentials)
    66	    user = db.query(User).filter(User.id == payload["sub"]).first()
    67	    if not user or user.role not in ("admin", "superadmin"):
    68	        raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin required")
    69	    return user
    70	
    71	
    72	def require_superadmin(
    73	    credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
    74	    db: Session = Depends(get_db),
    75	):
    76	    from backend.models import User
    77	    payload = decode_token(credentials.credentials)
    78	    user = db.query(User).filter(User.id == payload["sub"]).first()
    79	    if not user or user.role != "superadmin":
    80	        raise HTTPException(status.HTTP_403_FORBIDDEN, "Superadmin required")
    81	    return user
    82	
    83	
    84	# ═══════════════════════════════════════════════════
    85	#  PERMISSION SYSTEM
    86	# ═══════════════════════════════════════════════════
    87	
    88	def get_user_permissions(user_id: str, db: Session) -> dict:
    89	    """
    90	    Get permissions dict for a user.
    91	    - Superadmins always get full permissions (bypass everything).
    92	    - Regular users get their stored permissions or defaults.
    93	    - Auto-creates permission row if missing.
    94	    """
    95	    from backend.models import User, UserPermissions
    96	
    97	    user = db.query(User).filter(User.id == user_id).first()
    98	    if not user:
    99	        return dict(DEFAULT_PERMISSIONS)
   100	
   101	    if user.role == "superadmin":
   102	        return dict(SUPERADMIN_PERMISSIONS)
   103	
   104	    perms = db.query(UserPermissions).filter(UserPermissions.user_id == user_id).first()
   105	    if not perms:
   106	        perms = _create_default_permissions(user_id, db)
   107	
   108	    return _perms_to_dict(perms)
   109	
   110	
   111	def get_default_permissions_template(db: Session) -> dict:
   112	    """Get the stored default permissions template, or config defaults."""
   113	    from backend.models import UserPermissions
   114	    template = db.query(UserPermissions).filter(UserPermissions.user_id == "__defaults__").first()
   115	    if template:
   116	        return _perms_to_dict(template)
   117	    return dict(DEFAULT_PERMISSIONS)
   118	
   119	
   120	def ensure_user_permissions(user_id: str, db: Session):
   121	    """Make sure a user has a permissions row. Creates one from template if missing."""
   122	    from backend.models import UserPermissions
   123	    existing = db.query(UserPermissions).filter(UserPermissions.user_id == user_id).first()
   124	    if not existing:
   125	        _create_default_permissions(user_id, db)
   126	
   127	
   128	def _create_default_permissions(user_id: str, db: Session):
   129	    """Create a permissions row using the stored template or config defaults."""
   130	    from backend.models import UserPermissions
   131	    template = get_default_permissions_template(db)
   132	    perms = UserPermissions(user_id=user_id)
   133	    for field in PERMISSION_FIELDS:
   134	        if hasattr(perms, field):
   135	            setattr(perms, field, template.get(field, DEFAULT_PERMISSIONS.get(field)))
   136	    db.add(perms)
   137	    db.commit()
   138	    db.refresh(perms)
   139	    return perms
   140	
   141	
   142	def _perms_to_dict(perms) -> dict:
   143	    """Convert a UserPermissions ORM object to a dict."""
   144	    return {
   145	        "can_use_web_search": perms.can_use_web_search,
   146	        "can_use_ui_design": perms.can_use_ui_design,
   147	        "can_use_knowledge_base": perms.can_use_knowledge_base,
   148	        "can_use_gitlab": perms.can_use_gitlab,
   149	        "can_use_attachments": perms.can_use_attachments,
   150	        "can_export_pptx": perms.can_export_pptx,
   151	        "can_export_docx": perms.can_export_docx,
   152	        "allowed_models": perms.allowed_models or "all",
   153	        "max_tokens_cap": perms.max_tokens_cap or 4096,
   154	        "max_reasoning_budget": perms.max_reasoning_budget or 0,
   155	        "max_chats": perms.max_chats or 0,
   156	        "max_messages_per_day": perms.max_messages_per_day or 0,
   157	        "max_knowledge_bases": perms.max_knowledge_bases or 0,
   158	        "max_documents_per_kb": perms.max_documents_per_kb or 0,
   159	        "max_attachment_size_mb": perms.max_attachment_size_mb or 10,
   160	        "max_attachments_per_message": perms.max_attachments_per_message or 5,
   161	    }
   162	
   163	
   164	def check_feature(user_id: str, feature: str, db: Session):
   165	    """Check if user has access to a feature. Raises 403 if denied."""
   166	    perms = get_user_permissions(user_id, db)
   167	    key = f"can_{feature}" if not feature.startswith("can_") else feature
   168	    if not perms.get(key, False):
   169	        raise HTTPException(
   170	            status.HTTP_403_FORBIDDEN,
   171	            f"Feature '{feature.replace('can_', '').replace('use_', '')}' is not enabled for your account. Contact your admin.",
   172	        )
   173	
   174	
   175	def check_model_allowed(user_id: str, model_id: str, db: Session) -> str:
   176	    """Validate model access. Returns the model_id if allowed, or falls back to first allowed model."""
   177	    perms = get_user_permissions(user_id, db)
   178	    allowed = perms.get("allowed_models", "all")
   179	    if allowed == "all":
   180	        return model_id
   181	    allowed_list = [m.strip() for m in allowed.split(",") if m.strip()]
   182	    if model_id in allowed_list:
   183	        return model_id
   184	    if allowed_list:
   185	        return allowed_list[0]
   186	    return model_id
   187	
   188	
   189	def count_user_messages_today(user_id: str, db: Session) -> int:
   190	    """Count user messages sent today (UTC)."""
   191	    from backend.models import Chat, Message
   192	    today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
   193	    return (
   194	        db.query(Message)
   195	        .join(Chat)
   196	        .filter(
   197	            Chat.user_id == user_id,
   198	            Message.role == "user",
   199	            Message.created_at >= today_start,
   200	        )
   201	        .count()
   202	    )
   203	
   204	
   205	def count_user_chats(user_id: str, db: Session) -> int:
   206	    """Count total chats for a user."""
   207	    from backend.models import Chat
   208	    return db.query(Chat).filter(Chat.user_id == user_id).count()
   209	
   210	
   211	def count_user_knowledge_bases(user_id: str, db: Session) -> int:
   212	    """Count knowledge bases owned by user."""
   213	    from backend.models import KnowledgeBase
   214	    return db.query(KnowledgeBase).filter(KnowledgeBase.user_id == user_id).count()
   215	
   216	
   217	def count_kb_documents(kb_id: str, db: Session) -> int:
   218	    """Count documents in a knowledge base."""
   219	    from backend.models import KnowledgeDocument
   220	    return db.query(KnowledgeDocument).filter(KnowledgeDocument.knowledge_base_id == kb_id).count()│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [006]: backend/auth.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [007/69]: backend/config.py
│ LANGUAGE: python | LINES: 106 | SIZE: 5554 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	Son of Anton v4.2.0 — Configuration
     3	"""
     4	
     5	import os
     6	
     7	APP_VERSION = "4.2.0"
     8	
     9	# ═══════════════════════════════════════════════════
    10	#  AWS Bedrock
    11	# ═══════════════════════════════════════════════════
    12	BEDROCK_API_KEY = os.getenv("BEDROCK_API_KEY", "")
    13	AWS_REGION = os.getenv("AWS_REGION", "eu-central-1")
    14	BEDROCK_ENDPOINT = f"https://bedrock-runtime.{AWS_REGION}.amazonaws.com"
    15	
    16	# Models
    17	PRIMARY_MODEL = os.getenv("PRIMARY_MODEL", "eu.anthropic.claude-opus-4-6-v1")
    18	FAST_MODEL = os.getenv("FAST_MODEL", "eu.anthropic.claude-haiku-4-5-20251001-v1:0")
    19	
    20	AVAILABLE_MODELS = [
    21	    {"id": "eu.anthropic.claude-opus-4-6-v1", "label": "Opus 4.6", "tier": "expensive"},
    22	    {"id": "eu.anthropic.claude-haiku-4-5-20251001-v1:0", "label": "Haiku 4.5", "tier": "cheap"},
    23	]
    24	
    25	# ═══════════════════════════════════════════════════
    26	#  Auth
    27	# ═══════════════════════════════════════════════════
    28	JWT_SECRET = os.getenv("JWT_SECRET", "CreatedSystemsOverloadedFunctionsBySonOfAnton")
    29	SUPERADMIN_PASSWORD = os.getenv("SUPERADMIN_PASSWORD", "admin123")
    30	
    31	# ═══════════════════════════════════════════════════
    32	#  Database
    33	# ═══════════════════════════════════════════════════
    34	DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:////data/sonofanton.db")
    35	
    36	# ═══════════════════════════════════════════════════
    37	#  Quotas & Uploads
    38	# ═══════════════════════════════════════════════════
    39	DEFAULT_QUOTA = int(os.getenv("DEFAULT_QUOTA", "2000000"))
    40	MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "50"))
    41	MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024
    42	MAX_ATTACHMENT_BYTES = MAX_UPLOAD_BYTES
    43	
    44	# ═══════════════════════════════════════════════════
    45	#  Attachments
    46	# ═══════════════════════════════════════════════════
    47	ATTACHMENT_PATH = os.getenv("ATTACHMENT_PATH", "/data/uploads/chat_attachments")
    48	MAX_IMAGE_DIMENSION = 2048
    49	MAX_VIDEO_FRAMES = 6
    50	
    51	# ═══════════════════════════════════════════════════
    52	#  ChromaDB / RAG
    53	# ═══════════════════════════════════════════════════
    54	CHROMADB_PATH = os.getenv("CHROMADB_PATH", "/data/chromadb")
    55	
    56	# ═══════════════════════════════════════════════════
    57	#  Web Search (SerpAPI)
    58	# ═══════════════════════════════════════════════════
    59	SERPAPI_KEY = os.getenv("SERPAPI_KEY", "")
    60	
    61	# ═══════════════════════════════════════════════════
    62	#  App-level defaults
    63	# ═══════════════════════════════════════════════════
    64	REGISTRATION_ENABLED_DEFAULT = os.getenv("REGISTRATION_ENABLED", "true").lower() in ("true", "1", "yes")
    65	
    66	# ═══════════════════════════════════════════════════
    67	#  PERMISSION DEFAULTS — applied to new regular users
    68	# ═══════════════════════════════════════════════════
    69	DEFAULT_PERMISSIONS = {
    70	    "can_use_web_search": False,
    71	    "can_use_ui_design": False,
    72	    "can_use_knowledge_base": True,
    73	    "can_use_gitlab": False,
    74	    "can_use_attachments": True,
    75	    "can_export_pptx": True,
    76	    "can_export_docx": True,
    77	    "allowed_models": "eu.anthropic.claude-haiku-4-5-20251001-v1:0",
    78	    "max_tokens_cap": 4096,
    79	    "max_reasoning_budget": 0,
    80	    "max_chats": 50,
    81	    "max_messages_per_day": 100,
    82	    "max_knowledge_bases": 3,
    83	    "max_documents_per_kb": 20,
    84	    "max_attachment_size_mb": 10,
    85	    "max_attachments_per_message": 5,
    86	}
    87	
    88	SUPERADMIN_PERMISSIONS = {
    89	    "can_use_web_search": True,
    90	    "can_use_ui_design": True,
    91	    "can_use_knowledge_base": True,
    92	    "can_use_gitlab": True,
    93	    "can_use_attachments": True,
    94	    "can_export_pptx": True,
    95	    "can_export_docx": True,
    96	    "allowed_models": "all",
    97	    "max_tokens_cap": 65536,
    98	    "max_reasoning_budget": 32000,
    99	    "max_chats": 0,
   100	    "max_messages_per_day": 0,
   101	    "max_knowledge_bases": 0,
   102	    "max_documents_per_kb": 0,
   103	    "max_attachment_size_mb": 50,
   104	    "max_attachments_per_message": 20,
   105	}
   106	
   107	PERMISSION_FIELDS = list(DEFAULT_PERMISSIONS.keys())│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [007]: backend/config.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [008/69]: backend/database.py
│ LANGUAGE: python | LINES: 28 | SIZE: 777 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	Database engine and session factory.
     3	"""
     4	
     5	import os
     6	from sqlalchemy import create_engine
     7	from sqlalchemy.orm import sessionmaker, declarative_base
     8	
     9	from backend.config import DATABASE_URL
    10	
    11	# Ensure the directory for SQLite exists
    12	if DATABASE_URL.startswith("sqlite"):
    13	    db_path = DATABASE_URL.replace("sqlite:///", "").replace("sqlite:////", "/")
    14	    os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True)
    15	    connect_args = {"check_same_thread": False}
    16	else:
    17	    connect_args = {}
    18	
    19	engine = create_engine(DATABASE_URL, connect_args=connect_args, pool_pre_ping=True)
    20	SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
    21	Base = declarative_base()
    22	
    23	
    24	def get_db():
    25	    db = SessionLocal()
    26	    try:
    27	        yield db
    28	    finally:
    29	        db.close()│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [008]: backend/database.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [009/69]: backend/main.py
│ LANGUAGE: python | LINES: 142 | SIZE: 6283 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	Son of Anton v4.2.0 — Main FastAPI Application
     3	"""
     4	
     5	import time
     6	from pathlib import Path
     7	from contextlib import asynccontextmanager
     8	
     9	from fastapi import FastAPI, HTTPException, Request, Response
    10	from fastapi.staticfiles import StaticFiles
    11	from fastapi.responses import FileResponse
    12	from fastapi.middleware.cors import CORSMiddleware
    13	
    14	from backend.database import engine, Base
    15	from backend.seed import seed_superadmin
    16	from backend.config import APP_VERSION
    17	from backend.routes.auth_routes import router as auth_router
    18	from backend.routes.chat_routes import router as chat_router
    19	from backend.routes.admin_routes import router as admin_router
    20	from backend.routes.knowledge_routes import router as knowledge_router
    21	from backend.routes.files_routes import router as files_router
    22	from backend.routes.attachment_routes import router as attachment_router
    23	from backend.routes.gitlab_routes import router as gitlab_router
    24	from backend.routes.export_routes import router as export_router
    25	from backend.services.bedrock_service import close_http_client
    26	
    27	APP_BUILD_TIME = str(int(time.time()))
    28	
    29	
    30	def _run_migrations():
    31	    from sqlalchemy import inspect, text
    32	    try:
    33	        inspector = inspect(engine)
    34	        existing_tables = inspector.get_table_names()
    35	
    36	        if "chats" in existing_tables:
    37	            columns = {c["name"] for c in inspector.get_columns("chats")}
    38	            with engine.connect() as conn:
    39	                if "max_tokens" not in columns:
    40	                    conn.execute(text("ALTER TABLE chats ADD COLUMN max_tokens INTEGER DEFAULT 4096"))
    41	                if "reasoning_budget" not in columns:
    42	                    conn.execute(text("ALTER TABLE chats ADD COLUMN reasoning_budget INTEGER DEFAULT 0"))
    43	                if "linked_repo_id" not in columns:
    44	                    conn.execute(text("ALTER TABLE chats ADD COLUMN linked_repo_id VARCHAR(36)"))
    45	                conn.commit()
    46	
    47	        if "chat_attachments" not in existing_tables:
    48	            from backend.models import ChatAttachment
    49	            ChatAttachment.__table__.create(bind=engine, checkfirst=True)
    50	
    51	        if "user_permissions" not in existing_tables:
    52	            from backend.models import UserPermissions
    53	            UserPermissions.__table__.create(bind=engine, checkfirst=True)
    54	            print("  Created user_permissions table")
    55	
    56	        if "app_settings" not in existing_tables:
    57	            from backend.models import AppSettings
    58	            AppSettings.__table__.create(bind=engine, checkfirst=True)
    59	            print("  Created app_settings table")
    60	
    61	        for table_name in ["gitlab_settings", "linked_repos", "pending_actions"]:
    62	            if table_name not in existing_tables:
    63	                print(f"  Creating {table_name} table")
    64	
    65	        if "linked_repos" in existing_tables:
    66	            lr_columns = {c["name"] for c in inspector.get_columns("linked_repos")}
    67	            with engine.connect() as conn:
    68	                if "architecture_map" not in lr_columns:
    69	                    conn.execute(text("ALTER TABLE linked_repos ADD COLUMN architecture_map TEXT"))
    70	                if "map_status" not in lr_columns:
    71	                    conn.execute(text("ALTER TABLE linked_repos ADD COLUMN map_status VARCHAR(20) DEFAULT 'none'"))
    72	                if "map_generated_at" not in lr_columns:
    73	                    conn.execute(text("ALTER TABLE linked_repos ADD COLUMN map_generated_at DATETIME"))
    74	                conn.commit()
    75	    except Exception as e:
    76	        print(f"  Migration note: {e}")
    77	
    78	
    79	@asynccontextmanager
    80	async def lifespan(app: FastAPI):
    81	    Base.metadata.create_all(bind=engine)
    82	    _run_migrations()
    83	    seed_superadmin()
    84	    print(f"Son of Anton v{APP_VERSION} (build {APP_BUILD_TIME}) is online.")
    85	    yield
    86	    await close_http_client()
    87	    print("Son of Anton shutting down.")
    88	
    89	
    90	app = FastAPI(title="Son of Anton", description="Avatar of All Elements of Code", version=APP_VERSION, lifespan=lifespan)
    91	
    92	app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
    93	
    94	
    95	@app.middleware("http")
    96	async def add_cache_headers(request: Request, call_next):
    97	    response: Response = await call_next(request)
    98	    path = request.url.path
    99	    if path.startswith("/api"):
   100	        response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
   101	    elif path.startswith("/assets/") and any(c in path for c in [".js", ".css"]):
   102	        response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
   103	    else:
   104	        response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
   105	    response.headers["X-App-Version"] = APP_VERSION
   106	    return response
   107	
   108	
   109	@app.get("/api/version")
   110	def get_version():
   111	    return {"version": APP_VERSION, "build": APP_BUILD_TIME}
   112	
   113	
   114	app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
   115	app.include_router(chat_router, prefix="/api/chats", tags=["Chats"])
   116	app.include_router(admin_router, prefix="/api/admin", tags=["Admin"])
   117	app.include_router(knowledge_router, prefix="/api/knowledge", tags=["Knowledge"])
   118	app.include_router(files_router, prefix="/api/files", tags=["Files"])
   119	app.include_router(attachment_router, prefix="/api", tags=["Attachments"])
   120	app.include_router(gitlab_router, prefix="/api/gitlab", tags=["GitLab"])
   121	app.include_router(export_router, prefix="/api/export", tags=["Export"])
   122	
   123	FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
   124	
   125	if (FRONTEND_DIR / "assets").exists():
   126	    app.mount("/assets", StaticFiles(directory=str(FRONTEND_DIR / "assets")), name="static-assets")
   127	
   128	
   129	@app.get("/{full_path:path}", include_in_schema=False)
   130	async def serve_frontend(full_path: str):
   131	    if full_path.startswith("api"):
   132	        raise HTTPException(status_code=404, detail="Not found")
   133	    file_path = FRONTEND_DIR / full_path
   134	    if full_path and file_path.is_file():
   135	        resp = FileResponse(str(file_path))
   136	        resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
   137	        return resp
   138	    index = FRONTEND_DIR / "index.html"
   139	    if index.is_file():
   140	        resp = FileResponse(str(index))
   141	        resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
   142	        return resp
   143	    return {"message": "Son of Anton API is running. Frontend not built."}│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [009]: backend/main.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [010/69]: backend/migrations/005_attachments.sql
│ LANGUAGE: sql | LINES: 17 | SIZE: 818 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	-- Attachments table for chat file uploads
     2	CREATE TABLE IF NOT EXISTS attachments (
     3	    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
     4	    chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
     5	    message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
     6	    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
     7	    filename TEXT NOT NULL,
     8	    original_filename TEXT NOT NULL,
     9	    mime_type TEXT NOT NULL,
    10	    file_size INTEGER NOT NULL,
    11	    media_type TEXT NOT NULL CHECK (media_type IN ('image', 'video', 'document', 'text', 'unknown')),
    12	    storage_path TEXT NOT NULL,
    13	    created_at TIMESTAMPTZ DEFAULT NOW()
    14	);
    15	
    16	CREATE INDEX idx_attachments_chat ON attachments(chat_id);
    17	CREATE INDEX idx_attachments_message ON attachments(message_id);
    18	CREATE INDEX idx_attachments_user ON attachments(user_id);│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [010]: backend/migrations/005_attachments.sql


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [011/69]: backend/models.py
│ LANGUAGE: python | LINES: 221 | SIZE: 8665 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	SQLAlchemy ORM models — Son of Anton v4.2.0
     3	"""
     4	
     5	from datetime import datetime
     6	from uuid import uuid4
     7	
     8	from sqlalchemy import (
     9	    Column, String, Text, Boolean, BigInteger, Integer, DateTime, ForeignKey,
    10	)
    11	from sqlalchemy.orm import relationship
    12	
    13	from backend.database import Base
    14	
    15	
    16	def new_id() -> str:
    17	    return str(uuid4())
    18	
    19	
    20	def next_month() -> datetime:
    21	    now = datetime.utcnow()
    22	    if now.month == 12:
    23	        return datetime(now.year + 1, 1, 1)
    24	    return datetime(now.year, now.month + 1, 1)
    25	
    26	
    27	class User(Base):
    28	    __tablename__ = "users"
    29	
    30	    id = Column(String(36), primary_key=True, default=new_id)
    31	    username = Column(String(50), unique=True, nullable=False, index=True)
    32	    email = Column(String(120), unique=True, nullable=False)
    33	    password_hash = Column(String(200), nullable=False)
    34	    role = Column(String(20), default="user")
    35	    is_active = Column(Boolean, default=True)
    36	    quota_tokens_monthly = Column(BigInteger, default=2_000_000)
    37	    tokens_used_this_month = Column(BigInteger, default=0)
    38	    quota_reset_date = Column(DateTime, default=next_month)
    39	    created_at = Column(DateTime, default=datetime.utcnow)
    40	
    41	    chats = relationship("Chat", back_populates="user", cascade="all,delete-orphan")
    42	    permissions = relationship(
    43	        "UserPermissions", back_populates="user", uselist=False,
    44	        cascade="all,delete-orphan",
    45	    )
    46	
    47	
    48	class UserPermissions(Base):
    49	    __tablename__ = "user_permissions"
    50	
    51	    id = Column(String(36), primary_key=True, default=new_id)
    52	    user_id = Column(
    53	        String(36), ForeignKey("users.id", ondelete="CASCADE"),
    54	        unique=True, nullable=False, index=True,
    55	    )
    56	
    57	    can_use_web_search = Column(Boolean, default=False)
    58	    can_use_ui_design = Column(Boolean, default=False)
    59	    can_use_knowledge_base = Column(Boolean, default=True)
    60	    can_use_gitlab = Column(Boolean, default=False)
    61	    can_use_attachments = Column(Boolean, default=True)
    62	    can_export_pptx = Column(Boolean, default=True)
    63	    can_export_docx = Column(Boolean, default=True)
    64	
    65	    allowed_models = Column(Text, default="eu.anthropic.claude-haiku-4-5-20251001-v1:0")
    66	
    67	    max_tokens_cap = Column(Integer, default=4096)
    68	    max_reasoning_budget = Column(Integer, default=0)
    69	    max_chats = Column(Integer, default=50)
    70	    max_messages_per_day = Column(Integer, default=100)
    71	    max_knowledge_bases = Column(Integer, default=3)
    72	    max_documents_per_kb = Column(Integer, default=20)
    73	    max_attachment_size_mb = Column(Integer, default=10)
    74	    max_attachments_per_message = Column(Integer, default=5)
    75	
    76	    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
    77	
    78	    user = relationship("User", back_populates="permissions")
    79	
    80	
    81	class AppSettings(Base):
    82	    __tablename__ = "app_settings"
    83	
    84	    id = Column(String(36), primary_key=True, default=new_id)
    85	    registration_enabled = Column(Boolean, default=True)
    86	    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
    87	
    88	
    89	class Chat(Base):
    90	    __tablename__ = "chats"
    91	
    92	    id = Column(String(36), primary_key=True, default=new_id)
    93	    user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
    94	    title = Column(String(200), default="New Chat")
    95	    model = Column(String(100), default="eu.anthropic.claude-opus-4-6-v1")
    96	    knowledge_base_id = Column(String(36), nullable=True)
    97	    linked_repo_id = Column(String(36), nullable=True)
    98	    max_tokens = Column(Integer, default=4096)
    99	    reasoning_budget = Column(Integer, default=0)
   100	    created_at = Column(DateTime, default=datetime.utcnow)
   101	    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
   102	
   103	    user = relationship("User", back_populates="chats")
   104	    messages = relationship(
   105	        "Message", back_populates="chat",
   106	        cascade="all,delete-orphan", order_by="Message.created_at",
   107	    )
   108	    attachments = relationship(
   109	        "ChatAttachment", back_populates="chat",
   110	        cascade="all,delete-orphan",
   111	    )
   112	
   113	
   114	class Message(Base):
   115	    __tablename__ = "messages"
   116	
   117	    id = Column(String(36), primary_key=True, default=new_id)
   118	    chat_id = Column(String(36), ForeignKey("chats.id", ondelete="CASCADE"), nullable=False)
   119	    role = Column(String(20), nullable=False)
   120	    content = Column(Text, default="")
   121	    thinking_content = Column(Text, nullable=True)
   122	    input_tokens = Column(Integer, default=0)
   123	    output_tokens = Column(Integer, default=0)
   124	    created_at = Column(DateTime, default=datetime.utcnow)
   125	
   126	    chat = relationship("Chat", back_populates="messages")
   127	    attachments = relationship("ChatAttachment", back_populates="message")
   128	
   129	
   130	class ChatAttachment(Base):
   131	    __tablename__ = "chat_attachments"
   132	
   133	    id = Column(String(36), primary_key=True, default=new_id)
   134	    chat_id = Column(String(36), ForeignKey("chats.id", ondelete="CASCADE"), nullable=False)
   135	    message_id = Column(String(36), ForeignKey("messages.id", ondelete="SET NULL"), nullable=True)
   136	    filename = Column(String(200), nullable=False)
   137	    original_filename = Column(String(200), nullable=False)
   138	    mime_type = Column(String(100), nullable=False)
   139	    file_type = Column(String(20), nullable=False)
   140	    file_size = Column(Integer, default=0)
   141	    storage_path = Column(String(500), nullable=False)
   142	    text_extract = Column(Text, nullable=True)
   143	    created_at = Column(DateTime, default=datetime.utcnow)
   144	
   145	    chat = relationship("Chat", back_populates="attachments")
   146	    message = relationship("Message", back_populates="attachments")
   147	
   148	
   149	class KnowledgeBase(Base):
   150	    __tablename__ = "knowledge_bases"
   151	
   152	    id = Column(String(36), primary_key=True, default=new_id)
   153	    user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
   154	    name = Column(String(200), nullable=False)
   155	    description = Column(Text, default="")
   156	    document_count = Column(Integer, default=0)
   157	    chunk_count = Column(Integer, default=0)
   158	    total_characters = Column(BigInteger, default=0)
   159	    created_at = Column(DateTime, default=datetime.utcnow)
   160	
   161	
   162	class KnowledgeDocument(Base):
   163	    __tablename__ = "knowledge_documents"
   164	
   165	    id = Column(String(36), primary_key=True, default=new_id)
   166	    knowledge_base_id = Column(
   167	        String(36), ForeignKey("knowledge_bases.id", ondelete="CASCADE"), nullable=False,
   168	    )
   169	    filename = Column(String(200), nullable=False)
   170	    file_size = Column(Integer, default=0)
   171	    chunk_count = Column(Integer, default=0)
   172	    created_at = Column(DateTime, default=datetime.utcnow)
   173	
   174	
   175	# ═══════════════════════════════════════════════════════════
   176	#  GitLab Integration Models
   177	# ═══════════════════════════════════════════════════════════
   178	
   179	class GitLabSettings(Base):
   180	    __tablename__ = "gitlab_settings"
   181	
   182	    id = Column(String(36), primary_key=True, default=new_id)
   183	    gitlab_url = Column(String(500), nullable=False, default="")
   184	    gitlab_token = Column(String(500), nullable=False, default="")
   185	    is_active = Column(Boolean, default=False)
   186	    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
   187	
   188	
   189	class LinkedRepo(Base):
   190	    __tablename__ = "linked_repos"
   191	
   192	    id = Column(String(36), primary_key=True, default=new_id)
   193	    gitlab_project_id = Column(Integer, nullable=False)
   194	    name = Column(String(300), nullable=False)
   195	    path_with_namespace = Column(String(500), nullable=False)
   196	    default_branch = Column(String(100), default="main")
   197	    web_url = Column(String(500), default="")
   198	    description = Column(Text, default="")
   199	    architecture_map = Column(Text, nullable=True)
   200	    map_status = Column(String(20), default="none")
   201	    map_generated_at = Column(DateTime, nullable=True)
   202	    created_at = Column(DateTime, default=datetime.utcnow)
   203	
   204	    actions = relationship("PendingAction", back_populates="repo", cascade="all,delete-orphan")
   205	
   206	
   207	class PendingAction(Base):
   208	    __tablename__ = "pending_actions"
   209	
   210	    id = Column(String(36), primary_key=True, default=new_id)
   211	    linked_repo_id = Column(
   212	        String(36), ForeignKey("linked_repos.id", ondelete="CASCADE"), nullable=False,
   213	    )
   214	    action_type = Column(String(50), nullable=False)
   215	    title = Column(String(300), default="")
   216	    payload = Column(Text, nullable=False)
   217	    status = Column(String(20), default="pending")
   218	    result_message = Column(Text, default="")
   219	    created_at = Column(DateTime, default=datetime.utcnow)
   220	    resolved_at = Column(DateTime, nullable=True)
   221	
   222	    repo = relationship("LinkedRepo", back_populates="actions")│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [011]: backend/models.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [012/69]: backend/routes/admin_routes.py
│ LANGUAGE: python | LINES: 267 | SIZE: 10409 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	Superadmin routes: user management, stats, permissions, app settings — v4.2.0
     3	"""
     4	
     5	from pydantic import BaseModel
     6	from typing import Optional
     7	
     8	from fastapi import APIRouter, Depends, HTTPException
     9	from sqlalchemy.orm import Session
    10	from sqlalchemy import func
    11	
    12	from backend.database import get_db
    13	from backend.models import User, Chat, Message, KnowledgeBase, UserPermissions, AppSettings
    14	from backend.auth import (
    15	    require_superadmin, hash_password, get_user_permissions,
    16	    ensure_user_permissions, get_default_permissions_template,
    17	)
    18	from backend.config import PERMISSION_FIELDS, DEFAULT_PERMISSIONS, SUPERADMIN_PERMISSIONS, AVAILABLE_MODELS
    19	
    20	router = APIRouter()
    21	
    22	
    23	class UpdateUserBody(BaseModel):
    24	    email: Optional[str] = None
    25	    role: Optional[str] = None
    26	    is_active: Optional[bool] = None
    27	    quota_tokens_monthly: Optional[int] = None
    28	    password: Optional[str] = None
    29	
    30	
    31	class CreateUserBody(BaseModel):
    32	    username: str
    33	    email: str
    34	    password: str
    35	    role: str = "user"
    36	    quota_tokens_monthly: int = 2_000_000
    37	
    38	
    39	class PermissionsBody(BaseModel):
    40	    can_use_web_search: Optional[bool] = None
    41	    can_use_ui_design: Optional[bool] = None
    42	    can_use_knowledge_base: Optional[bool] = None
    43	    can_use_gitlab: Optional[bool] = None
    44	    can_use_attachments: Optional[bool] = None
    45	    can_export_pptx: Optional[bool] = None
    46	    can_export_docx: Optional[bool] = None
    47	    allowed_models: Optional[str] = None
    48	    max_tokens_cap: Optional[int] = None
    49	    max_reasoning_budget: Optional[int] = None
    50	    max_chats: Optional[int] = None
    51	    max_messages_per_day: Optional[int] = None
    52	    max_knowledge_bases: Optional[int] = None
    53	    max_documents_per_kb: Optional[int] = None
    54	    max_attachment_size_mb: Optional[int] = None
    55	    max_attachments_per_message: Optional[int] = None
    56	
    57	
    58	class AppSettingsBody(BaseModel):
    59	    registration_enabled: Optional[bool] = None
    60	
    61	
    62	# ═══════════════════════════════════════════════════
    63	#  Stats & Users
    64	# ═══════════════════════════════════════════════════
    65	
    66	@router.get("/stats")
    67	def get_stats(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
    68	    return {
    69	        "total_users": db.query(User).count(),
    70	        "active_users": db.query(User).filter(User.is_active == True).count(),
    71	        "total_chats": db.query(Chat).count(),
    72	        "total_messages": db.query(Message).count(),
    73	        "total_tokens_used": db.query(func.sum(User.tokens_used_this_month)).scalar() or 0,
    74	        "total_knowledge_bases": db.query(KnowledgeBase).count(),
    75	    }
    76	
    77	
    78	@router.get("/users")
    79	def list_users(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
    80	    users = db.query(User).order_by(User.created_at.desc()).all()
    81	    result = []
    82	    for u in users:
    83	        chat_count = db.query(Chat).filter(Chat.user_id == u.id).count()
    84	        result.append({
    85	            "id": u.id,
    86	            "username": u.username,
    87	            "email": u.email,
    88	            "role": u.role,
    89	            "is_active": u.is_active,
    90	            "quota_tokens_monthly": u.quota_tokens_monthly,
    91	            "tokens_used_this_month": u.tokens_used_this_month,
    92	            "chat_count": chat_count,
    93	            "created_at": str(u.created_at),
    94	        })
    95	    return result
    96	
    97	
    98	@router.post("/users")
    99	def create_user(body: CreateUserBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
   100	    if db.query(User).filter(
   101	        (User.username == body.username) | (User.email == body.email)
   102	    ).first():
   103	        raise HTTPException(409, "Username or email taken")
   104	    user = User(
   105	        username=body.username,
   106	        email=body.email,
   107	        password_hash=hash_password(body.password),
   108	        role=body.role,
   109	        quota_tokens_monthly=body.quota_tokens_monthly,
   110	    )
   111	    db.add(user)
   112	    db.commit()
   113	    db.refresh(user)
   114	    ensure_user_permissions(user.id, db)
   115	    return {"id": user.id, "username": user.username}
   116	
   117	
   118	@router.put("/users/{user_id}")
   119	def update_user(user_id: str, body: UpdateUserBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
   120	    user = db.query(User).filter(User.id == user_id).first()
   121	    if not user:
   122	        raise HTTPException(404)
   123	    if body.email is not None:
   124	        user.email = body.email
   125	    if body.role is not None:
   126	        user.role = body.role
   127	    if body.is_active is not None:
   128	        user.is_active = body.is_active
   129	    if body.quota_tokens_monthly is not None:
   130	        user.quota_tokens_monthly = body.quota_tokens_monthly
   131	    if body.password:
   132	        user.password_hash = hash_password(body.password)
   133	    db.commit()
   134	    return {"ok": True}
   135	
   136	
   137	@router.delete("/users/{user_id}")
   138	def delete_user(user_id: str, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
   139	    user = db.query(User).filter(User.id == user_id).first()
   140	    if not user:
   141	        raise HTTPException(404)
   142	    if user.role == "superadmin":
   143	        raise HTTPException(400, "Cannot delete superadmin")
   144	    db.delete(user)
   145	    db.commit()
   146	    return {"ok": True}
   147	
   148	
   149	@router.get("/chats")
   150	def list_all_chats(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
   151	    chats = db.query(Chat).order_by(Chat.updated_at.desc()).limit(200).all()
   152	    result = []
   153	    for c in chats:
   154	        user = db.query(User).filter(User.id == c.user_id).first()
   155	        msg_count = db.query(Message).filter(Message.chat_id == c.id).count()
   156	        result.append({
   157	            "id": c.id,
   158	            "title": c.title,
   159	            "username": user.username if user else "?",
   160	            "message_count": msg_count,
   161	            "updated_at": str(c.updated_at),
   162	        })
   163	    return result
   164	
   165	
   166	# ═══════════════════════════════════════════════════
   167	#  APP SETTINGS (registration toggle, etc.)
   168	# ═══════════════════════════════════════════════════
   169	
   170	@router.get("/app-settings")
   171	def get_app_settings(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
   172	    settings = db.query(AppSettings).first()
   173	    if not settings:
   174	        return {"registration_enabled": True}
   175	    return {
   176	        "registration_enabled": settings.registration_enabled,
   177	        "updated_at": str(settings.updated_at) if settings.updated_at else None,
   178	    }
   179	
   180	
   181	@router.put("/app-settings")
   182	def update_app_settings(body: AppSettingsBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
   183	    settings = db.query(AppSettings).first()
   184	    if not settings:
   185	        settings = AppSettings()
   186	        db.add(settings)
   187	    if body.registration_enabled is not None:
   188	        settings.registration_enabled = body.registration_enabled
   189	    db.commit()
   190	    db.refresh(settings)
   191	    return {
   192	        "registration_enabled": settings.registration_enabled,
   193	        "updated_at": str(settings.updated_at) if settings.updated_at else None,
   194	    }
   195	
   196	
   197	# ═══════════════════════════════════════════════════
   198	#  PERMISSIONS MANAGEMENT
   199	# ═══════════════════════════════════════════════════
   200	
   201	@router.get("/models")
   202	def list_available_models(admin: User = Depends(require_superadmin)):
   203	    return AVAILABLE_MODELS
   204	
   205	
   206	@router.get("/permissions/defaults")
   207	def get_defaults(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
   208	    return get_default_permissions_template(db)
   209	
   210	
   211	@router.put("/permissions/defaults")
   212	def update_defaults(body: PermissionsBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
   213	    template = db.query(UserPermissions).filter(UserPermissions.user_id == "__defaults__").first()
   214	    if not template:
   215	        template = UserPermissions(user_id="__defaults__")
   216	        db.add(template)
   217	    _apply_permissions_body(template, body)
   218	    db.commit()
   219	    return get_default_permissions_template(db)
   220	
   221	
   222	@router.post("/permissions/apply-defaults")
   223	def apply_defaults_to_all(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
   224	    """Apply default permissions template to ALL non-superadmin users."""
   225	    template = get_default_permissions_template(db)
   226	    users = db.query(User).filter(User.role != "superadmin").all()
   227	    count = 0
   228	    for user in users:
   229	        perms = db.query(UserPermissions).filter(UserPermissions.user_id == user.id).first()
   230	        if not perms:
   231	            perms = UserPermissions(user_id=user.id)
   232	            db.add(perms)
   233	        for field in PERMISSION_FIELDS:
   234	            if hasattr(perms, field):
   235	                setattr(perms, field, template.get(field))
   236	        count += 1
   237	    db.commit()
   238	    return {"ok": True, "users_updated": count}
   239	
   240	
   241	@router.get("/users/{user_id}/permissions")
   242	def get_user_perms(user_id: str, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
   243	    user = db.query(User).filter(User.id == user_id).first()
   244	    if not user:
   245	        raise HTTPException(404, "User not found")
   246	    return get_user_permissions(user_id, db)
   247	
   248	
   249	@router.put("/users/{user_id}/permissions")
   250	def update_user_perms(user_id: str, body: PermissionsBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
   251	    user = db.query(User).filter(User.id == user_id).first()
   252	    if not user:
   253	        raise HTTPException(404, "User not found")
   254	    if user.role == "superadmin":
   255	        raise HTTPException(400, "Cannot modify superadmin permissions — they always have full access")
   256	    ensure_user_permissions(user_id, db)
   257	    perms = db.query(UserPermissions).filter(UserPermissions.user_id == user_id).first()
   258	    _apply_permissions_body(perms, body)
   259	    db.commit()
   260	    return get_user_permissions(user_id, db)
   261	
   262	
   263	def _apply_permissions_body(perms: UserPermissions, body: PermissionsBody):
   264	    """Apply non-None fields from the body to a permissions object."""
   265	    data = body.dict(exclude_none=True)
   266	    for field, value in data.items():
   267	        if hasattr(perms, field):
   268	            setattr(perms, field, value)│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [012]: backend/routes/admin_routes.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [013/69]: backend/routes/attachment_routes.py
│ LANGUAGE: python | LINES: 115 | SIZE: 4813 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	Chat attachment upload, serve, delete — v4.2.0 with permission enforcement.
     3	"""
     4	
     5	import os
     6	from typing import Optional
     7	
     8	from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Request
     9	from fastapi.responses import FileResponse
    10	from sqlalchemy.orm import Session
    11	
    12	from backend.database import get_db
    13	from backend.models import User, Chat, ChatAttachment
    14	from backend.auth import get_current_user, decode_token, get_user_permissions, check_feature
    15	from backend.services import attachment_service
    16	from backend.config import MAX_ATTACHMENT_BYTES
    17	
    18	router = APIRouter()
    19	
    20	
    21	def _get_user_flexible(request: Request, db: Session, token_param: Optional[str] = None) -> User:
    22	    raw_token = None
    23	    auth_header = request.headers.get("authorization", "")
    24	    if auth_header.startswith("Bearer "):
    25	        raw_token = auth_header[7:]
    26	    if not raw_token and token_param:
    27	        raw_token = token_param
    28	    if not raw_token:
    29	        raise HTTPException(401, "Authentication required")
    30	    payload = decode_token(raw_token)
    31	    user = db.query(User).filter(User.id == payload["sub"]).first()
    32	    if not user or not user.is_active:
    33	        raise HTTPException(401, "User not found or inactive")
    34	    return user
    35	
    36	
    37	@router.post("/chats/{chat_id}/attachments")
    38	async def upload_attachments(
    39	    chat_id: str,
    40	    files: list[UploadFile] = File(...),
    41	    user: User = Depends(get_current_user),
    42	    db: Session = Depends(get_db),
    43	):
    44	    check_feature(user.id, "use_attachments", db)
    45	    perms = get_user_permissions(user.id, db)
    46	
    47	    max_per_msg = perms.get("max_attachments_per_message", 5)
    48	    if len(files) > max_per_msg:
    49	        raise HTTPException(400, f"Too many files. Max {max_per_msg} per message.")
    50	
    51	    max_size = perms.get("max_attachment_size_mb", 10) * 1024 * 1024
    52	
    53	    chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
    54	    if not chat:
    55	        raise HTTPException(404, "Chat not found")
    56	
    57	    results = []
    58	    for file in files:
    59	        filename = file.filename or "file"
    60	        try:
    61	            content = await file.read()
    62	            if len(content) > max_size:
    63	                results.append({"error": f"Too large: {filename} ({len(content) // 1024 // 1024}MB). Your limit: {perms.get('max_attachment_size_mb', 10)}MB."})
    64	                continue
    65	
    66	            meta = attachment_service.save_attachment(
    67	                chat_id=chat_id, filename=filename,
    68	                content=content, content_type=file.content_type,
    69	            )
    70	            att = ChatAttachment(
    71	                id=meta["id"], chat_id=chat_id,
    72	                filename=meta["filename"], original_filename=meta["original_filename"],
    73	                mime_type=meta["mime_type"], file_type=meta["file_type"],
    74	                file_size=meta["file_size"], storage_path=meta["storage_path"],
    75	                text_extract=meta.get("text_extract"),
    76	            )
    77	            db.add(att); db.commit(); db.refresh(att)
    78	            results.append(_att_dict(att))
    79	        except Exception as e:
    80	            results.append({"error": f"Failed: {filename}: {str(e)}"})
    81	
    82	    return {"attachments": results}
    83	
    84	
    85	@router.get("/attachments/{attachment_id}/file")
    86	def serve_attachment(attachment_id: str, request: Request, token: Optional[str] = Query(None), db: Session = Depends(get_db)):
    87	    user = _get_user_flexible(request, db, token)
    88	    att = db.query(ChatAttachment).filter(ChatAttachment.id == attachment_id).first()
    89	    if not att: raise HTTPException(404, "Attachment not found")
    90	    chat = db.query(Chat).filter(Chat.id == att.chat_id).first()
    91	    if not chat or (chat.user_id != user.id and user.role != "superadmin"):
    92	        raise HTTPException(403, "Access denied")
    93	    if not os.path.exists(att.storage_path):
    94	        raise HTTPException(404, "File not found on disk")
    95	    return FileResponse(att.storage_path, media_type=att.mime_type, filename=att.original_filename)
    96	
    97	
    98	@router.delete("/attachments/{attachment_id}")
    99	def delete_attachment(attachment_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
   100	    att = db.query(ChatAttachment).filter(ChatAttachment.id == attachment_id).first()
   101	    if not att: raise HTTPException(404)
   102	    chat = db.query(Chat).filter(Chat.id == att.chat_id).first()
   103	    if not chat or (chat.user_id != user.id and user.role != "superadmin"):
   104	        raise HTTPException(403)
   105	    attachment_service.delete_attachment_file(att.storage_path)
   106	    db.delete(att); db.commit()
   107	    return {"ok": True}
   108	
   109	
   110	def _att_dict(att):
   111	    return {
   112	        "id": att.id, "chat_id": att.chat_id, "message_id": att.message_id,
   113	        "filename": att.filename, "original_filename": att.original_filename,
   114	        "mime_type": att.mime_type, "file_type": att.file_type,
   115	        "file_size": att.file_size, "created_at": str(att.created_at),
   116	    }│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [013]: backend/routes/attachment_routes.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [014/69]: backend/routes/attachment_routes_15.py
│ LANGUAGE: python | LINES: 158 | SIZE: 5059 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	Chat attachment upload, serve, and delete routes.
     3	"""
     4	
     5	import os
     6	from typing import Optional
     7	
     8	from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
     9	from fastapi.responses import FileResponse
    10	from sqlalchemy.orm import Session
    11	
    12	from backend.database import get_db
    13	from backend.models import User, Chat, ChatAttachment
    14	from backend.auth import get_current_user, decode_token
    15	from backend.services import attachment_service
    16	from backend.config import MAX_ATTACHMENT_BYTES
    17	
    18	router = APIRouter()
    19	
    20	
    21	@router.post("/chats/{chat_id}/attachments")
    22	async def upload_attachments(
    23	    chat_id: str,
    24	    files: list[UploadFile] = File(...),
    25	    user: User = Depends(get_current_user),
    26	    db: Session = Depends(get_db),
    27	):
    28	    """Upload one or more files as chat attachments. Returns attachment metadata."""
    29	    chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
    30	    if not chat:
    31	        raise HTTPException(404, "Chat not found")
    32	
    33	    results = []
    34	    for file in files:
    35	        filename = file.filename or "file"
    36	        try:
    37	            content = await file.read()
    38	            if len(content) > MAX_ATTACHMENT_BYTES:
    39	                results.append({
    40	                    "error": f"File too large: {filename} ({len(content) // 1024 // 1024}MB). Max {MAX_ATTACHMENT_BYTES // 1024 // 1024}MB.",
    41	                })
    42	                continue
    43	
    44	            meta = attachment_service.save_attachment(
    45	                chat_id=chat_id,
    46	                filename=filename,
    47	                content=content,
    48	                content_type=file.content_type,
    49	            )
    50	
    51	            att = ChatAttachment(
    52	                id=meta["id"],
    53	                chat_id=chat_id,
    54	                filename=meta["filename"],
    55	                original_filename=meta["original_filename"],
    56	                mime_type=meta["mime_type"],
    57	                file_type=meta["file_type"],
    58	                file_size=meta["file_size"],
    59	                storage_path=meta["storage_path"],
    60	                text_extract=meta.get("text_extract"),
    61	            )
    62	            db.add(att)
    63	            db.commit()
    64	            db.refresh(att)
    65	
    66	            results.append(_att_dict(att))
    67	
    68	        except Exception as e:
    69	            results.append({"error": f"Failed to upload {filename}: {str(e)}"})
    70	
    71	    return {"attachments": results}
    72	
    73	
    74	@router.get("/attachments/{attachment_id}/file")
    75	def serve_attachment(
    76	    attachment_id: str,
    77	    token: Optional[str] = Query(None),
    78	    user: Optional[User] = Depends(_optional_current_user),
    79	    db: Session = Depends(get_db),
    80	):
    81	    """
    82	    Serve an attachment file.
    83	    Supports both Bearer header auth and ?token= query param
    84	    (needed for <img> tags that can't send headers).
    85	    """
    86	    # Try query param auth if header auth didn't work
    87	    if user is None and token:
    88	        try:
    89	            payload = decode_token(token)
    90	            user = db.query(User).filter(User.id == payload["sub"]).first()
    91	        except Exception:
    92	            pass
    93	
    94	    if user is None:
    95	        raise HTTPException(401, "Authentication required")
    96	
    97	    att = db.query(ChatAttachment).filter(ChatAttachment.id == attachment_id).first()
    98	    if not att:
    99	        raise HTTPException(404, "Attachment not found")
   100	
   101	    chat = db.query(Chat).filter(Chat.id == att.chat_id).first()
   102	    if not chat or (chat.user_id != user.id and user.role != "superadmin"):
   103	        raise HTTPException(403, "Access denied")
   104	
   105	    if not os.path.exists(att.storage_path):
   106	        raise HTTPException(404, "File not found on disk")
   107	
   108	    return FileResponse(
   109	        att.storage_path,
   110	        media_type=att.mime_type,
   111	        filename=att.original_filename,
   112	    )
   113	
   114	
   115	@router.delete("/attachments/{attachment_id}")
   116	def delete_attachment(
   117	    attachment_id: str,
   118	    user: User = Depends(get_current_user),
   119	    db: Session = Depends(get_db),
   120	):
   121	    """Delete a single attachment."""
   122	    att = db.query(ChatAttachment).filter(ChatAttachment.id == attachment_id).first()
   123	    if not att:
   124	        raise HTTPException(404)
   125	
   126	    chat = db.query(Chat).filter(Chat.id == att.chat_id).first()
   127	    if not chat or (chat.user_id != user.id and user.role != "superadmin"):
   128	        raise HTTPException(403)
   129	
   130	    attachment_service.delete_attachment_file(att.storage_path)
   131	    db.delete(att)
   132	    db.commit()
   133	    return {"ok": True}
   134	
   135	
   136	def _optional_current_user(
   137	    db: Session = Depends(get_db),
   138	):
   139	    """
   140	    A dependency that tries to get current user but returns None on failure.
   141	    This allows the endpoint to also accept ?token= query param.
   142	    """
   143	    # This is a placeholder — the actual auth is handled in the route
   144	    # by checking both header and query param
   145	    return None
   146	
   147	
   148	def _att_dict(att: ChatAttachment) -> dict:
   149	    return {
   150	        "id": att.id,
   151	        "chat_id": att.chat_id,
   152	        "message_id": att.message_id,
   153	        "filename": att.filename,
   154	        "original_filename": att.original_filename,
   155	        "mime_type": att.mime_type,
   156	        "file_type": att.file_type,
   157	        "file_size": att.file_size,
   158	        "created_at": str(att.created_at),
   159	    }│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [014]: backend/routes/attachment_routes_15.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [015/69]: backend/routes/attachment_routes_16.py
│ LANGUAGE: python | LINES: 164 | SIZE: 5228 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	Chat attachment upload, serve, and delete routes.
     3	"""
     4	
     5	import os
     6	from typing import Optional
     7	
     8	from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Request
     9	from fastapi.responses import FileResponse
    10	from sqlalchemy.orm import Session
    11	
    12	from backend.database import get_db
    13	from backend.models import User, Chat, ChatAttachment
    14	from backend.auth import get_current_user, decode_token
    15	from backend.services import attachment_service
    16	from backend.config import MAX_ATTACHMENT_BYTES
    17	
    18	router = APIRouter()
    19	
    20	
    21	def _get_user_from_request(request: Request, db: Session, token_param: Optional[str] = None) -> User:
    22	    """
    23	    Resolve user from either:
    24	    1. Authorization: Bearer <token> header
    25	    2. ?token=<token> query parameter (for img/video tags)
    26	    """
    27	    raw_token = None
    28	
    29	    # Try header first
    30	    auth_header = request.headers.get("authorization", "")
    31	    if auth_header.startswith("Bearer "):
    32	        raw_token = auth_header[7:]
    33	
    34	    # Fall back to query param
    35	    if not raw_token and token_param:
    36	        raw_token = token_param
    37	
    38	    if not raw_token:
    39	        raise HTTPException(401, "Authentication required")
    40	
    41	    payload = decode_token(raw_token)
    42	    user = db.query(User).filter(User.id == payload["sub"]).first()
    43	    if not user or not user.is_active:
    44	        raise HTTPException(401, "User not found or inactive")
    45	    return user
    46	
    47	
    48	@router.post("/chats/{chat_id}/attachments")
    49	async def upload_attachments(
    50	    chat_id: str,
    51	    files: list[UploadFile] = File(...),
    52	    user: User = Depends(get_current_user),
    53	    db: Session = Depends(get_db),
    54	):
    55	    """Upload one or more files as chat attachments. Returns attachment metadata."""
    56	    chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
    57	    if not chat:
    58	        raise HTTPException(404, "Chat not found")
    59	
    60	    results = []
    61	    for file in files:
    62	        filename = file.filename or "file"
    63	        try:
    64	            content = await file.read()
    65	            if len(content) > MAX_ATTACHMENT_BYTES:
    66	                results.append({
    67	                    "error": f"File too large: {filename} ({len(content) // 1024 // 1024}MB). Max {MAX_ATTACHMENT_BYTES // 1024 // 1024}MB.",
    68	                })
    69	                continue
    70	
    71	            meta = attachment_service.save_attachment(
    72	                chat_id=chat_id,
    73	                filename=filename,
    74	                content=content,
    75	                content_type=file.content_type,
    76	            )
    77	
    78	            att = ChatAttachment(
    79	                id=meta["id"],
    80	                chat_id=chat_id,
    81	                filename=meta["filename"],
    82	                original_filename=meta["original_filename"],
    83	                mime_type=meta["mime_type"],
    84	                file_type=meta["file_type"],
    85	                file_size=meta["file_size"],
    86	                storage_path=meta["storage_path"],
    87	                text_extract=meta.get("text_extract"),
    88	            )
    89	            db.add(att)
    90	            db.commit()
    91	            db.refresh(att)
    92	
    93	            results.append(_att_dict(att))
    94	
    95	        except Exception as e:
    96	            results.append({"error": f"Failed to upload {filename}: {str(e)}"})
    97	
    98	    return {"attachments": results}
    99	
   100	
   101	@router.get("/attachments/{attachment_id}/file")
   102	def serve_attachment(
   103	    attachment_id: str,
   104	    request: Request,
   105	    token: Optional[str] = Query(None),
   106	    db: Session = Depends(get_db),
   107	):
   108	    """
   109	    Serve an attachment file.
   110	    Supports both Bearer header auth and ?token= query param
   111	    (needed for <img> tags that can't send headers).
   112	    """
   113	    user = _get_user_from_request(request, db, token)
   114	
   115	    att = db.query(ChatAttachment).filter(ChatAttachment.id == attachment_id).first()
   116	    if not att:
   117	        raise HTTPException(404, "Attachment not found")
   118	
   119	    chat = db.query(Chat).filter(Chat.id == att.chat_id).first()
   120	    if not chat or (chat.user_id != user.id and user.role != "superadmin"):
   121	        raise HTTPException(403, "Access denied")
   122	
   123	    if not os.path.exists(att.storage_path):
   124	        raise HTTPException(404, "File not found on disk")
   125	
   126	    return FileResponse(
   127	        att.storage_path,
   128	        media_type=att.mime_type,
   129	        filename=att.original_filename,
   130	    )
   131	
   132	
   133	@router.delete("/attachments/{attachment_id}")
   134	def delete_attachment(
   135	    attachment_id: str,
   136	    user: User = Depends(get_current_user),
   137	    db: Session = Depends(get_db),
   138	):
   139	    """Delete a single attachment."""
   140	    att = db.query(ChatAttachment).filter(ChatAttachment.id == attachment_id).first()
   141	    if not att:
   142	        raise HTTPException(404)
   143	
   144	    chat = db.query(Chat).filter(Chat.id == att.chat_id).first()
   145	    if not chat or (chat.user_id != user.id and user.role != "superadmin"):
   146	        raise HTTPException(403)
   147	
   148	    attachment_service.delete_attachment_file(att.storage_path)
   149	    db.delete(att)
   150	    db.commit()
   151	    return {"ok": True}
   152	
   153	
   154	def _att_dict(att: ChatAttachment) -> dict:
   155	    return {
   156	        "id": att.id,
   157	        "chat_id": att.chat_id,
   158	        "message_id": att.message_id,
   159	        "filename": att.filename,
   160	        "original_filename": att.original_filename,
   161	        "mime_type": att.mime_type,
   162	        "file_type": att.file_type,
   163	        "file_size": att.file_size,
   164	        "created_at": str(att.created_at),
   165	    }│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [015]: backend/routes/attachment_routes_16.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [016/69]: backend/routes/attachments.py
│ LANGUAGE: python | LINES: 156 | SIZE: 4776 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	Son of Anton — Attachment Upload Routes
     3	Handles file uploads for chat messages.
     4	"""
     5	
     6	import os
     7	import uuid
     8	import shutil
     9	from pathlib import Path
    10	from datetime import datetime, timezone
    11	
    12	from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, Form
    13	from typing import List
    14	
    15	from ..auth import get_current_user
    16	from ..database import get_db
    17	from ..services.file_processor import classify_media, validate_file
    18	
    19	router = APIRouter(prefix="/chats/{chat_id}/attachments", tags=["attachments"])
    20	
    21	UPLOAD_DIR = os.getenv("UPLOAD_DIR", "uploads")
    22	Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
    23	
    24	
    25	@router.post("")
    26	async def upload_attachments(
    27	    chat_id: str,
    28	    files: List[UploadFile] = File(...),
    29	    user=Depends(get_current_user),
    30	    db=Depends(get_db),
    31	):
    32	    """Upload one or more files to a chat. Returns attachment metadata."""
    33	
    34	    # Verify chat belongs to user
    35	    chat = await db.fetchrow(
    36	        "SELECT id, user_id FROM chats WHERE id = $1", uuid.UUID(chat_id)
    37	    )
    38	    if not chat:
    39	        raise HTTPException(404, "Chat not found")
    40	    if str(chat["user_id"]) != str(user["id"]):
    41	        raise HTTPException(403, "Not your chat")
    42	
    43	    if len(files) > 10:
    44	        raise HTTPException(400, "Maximum 10 files per upload")
    45	
    46	    results = []
    47	
    48	    for f in files:
    49	        # Read file content to get size
    50	        content = await f.read()
    51	        size = len(content)
    52	
    53	        # Validate
    54	        ok, error = validate_file(f.filename or "file", f.content_type or "", size)
    55	        if not ok:
    56	            raise HTTPException(400, f"File '{f.filename}': {error}")
    57	
    58	        media_type = classify_media(f.content_type or "")
    59	
    60	        # Generate unique storage filename
    61	        ext = Path(f.filename or "file").suffix or ".bin"
    62	        storage_name = f"{uuid.uuid4().hex}{ext}"
    63	        chat_dir = Path(UPLOAD_DIR) / chat_id
    64	        chat_dir.mkdir(parents=True, exist_ok=True)
    65	        storage_path = f"{chat_id}/{storage_name}"
    66	        full_path = chat_dir / storage_name
    67	
    68	        # Write file
    69	        full_path.write_bytes(content)
    70	
    71	        # Insert DB record
    72	        att = await db.fetchrow(
    73	            """
    74	            INSERT INTO attachments (chat_id, user_id, filename, original_filename, mime_type, file_size, media_type, storage_path)
    75	            VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
    76	            RETURNING id, filename, original_filename, mime_type, file_size, media_type, created_at
    77	            """,
    78	            uuid.UUID(chat_id),
    79	            user["id"],
    80	            storage_name,
    81	            f.filename or "file",
    82	            f.content_type or "application/octet-stream",
    83	            size,
    84	            media_type,
    85	            storage_path,
    86	        )
    87	
    88	        results.append({
    89	            "id": str(att["id"]),
    90	            "filename": att["original_filename"],
    91	            "mime_type": att["mime_type"],
    92	            "file_size": att["file_size"],
    93	            "media_type": att["media_type"],
    94	            "created_at": att["created_at"].isoformat(),
    95	        })
    96	
    97	    return results
    98	
    99	
   100	@router.get("")
   101	async def list_attachments(
   102	    chat_id: str,
   103	    user=Depends(get_current_user),
   104	    db=Depends(get_db),
   105	):
   106	    """List all attachments for a chat."""
   107	    chat = await db.fetchrow(
   108	        "SELECT id, user_id FROM chats WHERE id = $1", uuid.UUID(chat_id)
   109	    )
   110	    if not chat:
   111	        raise HTTPException(404, "Chat not found")
   112	    if str(chat["user_id"]) != str(user["id"]):
   113	        raise HTTPException(403, "Not your chat")
   114	
   115	    rows = await db.fetch(
   116	        """
   117	        SELECT id, original_filename as filename, mime_type, file_size, media_type, message_id, created_at
   118	        FROM attachments WHERE chat_id = $1 ORDER BY created_at
   119	        """,
   120	        uuid.UUID(chat_id),
   121	    )
   122	    return [dict(r) for r in rows]
   123	
   124	
   125	@router.get("/{attachment_id}/preview")
   126	async def preview_attachment(
   127	    chat_id: str,
   128	    attachment_id: str,
   129	    user=Depends(get_current_user),
   130	    db=Depends(get_db),
   131	):
   132	    """Return raw file content for preview (images mainly)."""
   133	    from fastapi.responses import FileResponse
   134	
   135	    att = await db.fetchrow(
   136	        """
   137	        SELECT a.*, c.user_id as chat_owner
   138	        FROM attachments a JOIN chats c ON a.chat_id = c.id
   139	        WHERE a.id = $1 AND a.chat_id = $2
   140	        """,
   141	        uuid.UUID(attachment_id),
   142	        uuid.UUID(chat_id),
   143	    )
   144	    if not att:
   145	        raise HTTPException(404, "Attachment not found")
   146	    if str(att["chat_owner"]) != str(user["id"]):
   147	        raise HTTPException(403, "Not your attachment")
   148	
   149	    full_path = Path(UPLOAD_DIR) / att["storage_path"]
   150	    if not full_path.exists():
   151	        raise HTTPException(404, "File not found on disk")
   152	
   153	    return FileResponse(
   154	        path=str(full_path),
   155	        media_type=att["mime_type"],
   156	        filename=att["original_filename"],
   157	    )│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [016]: backend/routes/attachments.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [017/69]: backend/routes/auth_routes.py
│ LANGUAGE: python | LINES: 103 | SIZE: 3332 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	Authentication routes: register, login, profile — with permissions and registration toggle.
     3	"""
     4	
     5	from pydantic import BaseModel
     6	from fastapi import APIRouter, Depends, HTTPException, status
     7	from sqlalchemy.orm import Session
     8	
     9	from backend.database import get_db
    10	from backend.models import User, AppSettings
    11	from backend.auth import (
    12	    hash_password, verify_password, create_token, get_current_user,
    13	    get_user_permissions, ensure_user_permissions,
    14	)
    15	from backend import config
    16	
    17	router = APIRouter()
    18	
    19	
    20	class RegisterBody(BaseModel):
    21	    username: str
    22	    email: str
    23	    password: str
    24	
    25	
    26	class LoginBody(BaseModel):
    27	    username: str
    28	    password: str
    29	
    30	
    31	@router.get("/registration-status")
    32	def registration_status(db: Session = Depends(get_db)):
    33	    """Public endpoint — no auth required. Frontend checks this to show/hide register form."""
    34	    settings = db.query(AppSettings).first()
    35	    enabled = settings.registration_enabled if settings else config.REGISTRATION_ENABLED_DEFAULT
    36	    return {"registration_enabled": enabled}
    37	
    38	
    39	@router.post("/register")
    40	def register(body: RegisterBody, db: Session = Depends(get_db)):
    41	    # Check if registration is enabled
    42	    settings = db.query(AppSettings).first()
    43	    enabled = settings.registration_enabled if settings else config.REGISTRATION_ENABLED_DEFAULT
    44	    if not enabled:
    45	        raise HTTPException(
    46	            status.HTTP_403_FORBIDDEN,
    47	            "Registration is currently disabled. Contact your administrator.",
    48	        )
    49	
    50	    if db.query(User).filter(
    51	        (User.username == body.username) | (User.email == body.email)
    52	    ).first():
    53	        raise HTTPException(status.HTTP_409_CONFLICT, "Username or email already taken")
    54	
    55	    user = User(
    56	        username=body.username,
    57	        email=body.email,
    58	        password_hash=hash_password(body.password),
    59	        role="user",
    60	        quota_tokens_monthly=config.DEFAULT_QUOTA,
    61	    )
    62	    db.add(user)
    63	    db.commit()
    64	    db.refresh(user)
    65	
    66	    ensure_user_permissions(user.id, db)
    67	
    68	    token = create_token(user.id, user.role)
    69	    perms = get_user_permissions(user.id, db)
    70	    return {"token": token, "user": _user_dict(user, perms)}
    71	
    72	
    73	@router.post("/login")
    74	def login(body: LoginBody, db: Session = Depends(get_db)):
    75	    user = db.query(User).filter(User.username == body.username).first()
    76	    if not user or not verify_password(body.password, user.password_hash):
    77	        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid credentials")
    78	    if not user.is_active:
    79	        raise HTTPException(status.HTTP_403_FORBIDDEN, "Account disabled")
    80	    token = create_token(user.id, user.role)
    81	    perms = get_user_permissions(user.id, db)
    82	    return {"token": token, "user": _user_dict(user, perms)}
    83	
    84	
    85	@router.get("/me")
    86	def me(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
    87	    perms = get_user_permissions(user.id, db)
    88	    return _user_dict(user, perms)
    89	
    90	
    91	def _user_dict(u: User, perms: dict = None) -> dict:
    92	    d = {
    93	        "id": u.id,
    94	        "username": u.username,
    95	        "email": u.email,
    96	        "role": u.role,
    97	        "is_active": u.is_active,
    98	        "quota_tokens_monthly": u.quota_tokens_monthly,
    99	        "tokens_used_this_month": u.tokens_used_this_month,
   100	        "created_at": str(u.created_at),
   101	    }
   102	    if perms is not None:
   103	        d["permissions"] = perms
   104	    return d│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [017]: backend/routes/auth_routes.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [018/69]: backend/routes/chat_routes.py
│ LANGUAGE: python | LINES: 349 | SIZE: 13174 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	Chat CRUD + message streaming — v4.2.0 with permission enforcement.
     3	"""
     4	
     5	import json
     6	from datetime import datetime
     7	from pydantic import BaseModel
     8	from typing import Optional
     9	
    10	from fastapi import APIRouter, Depends, HTTPException
    11	from fastapi.responses import StreamingResponse
    12	from sqlalchemy.orm import Session
    13	
    14	from backend.database import get_db
    15	from backend.models import User, Chat, Message, ChatAttachment, LinkedRepo, GitLabSettings
    16	from backend.auth import (
    17	    get_current_user, get_user_permissions, check_feature,
    18	    check_model_allowed, count_user_chats, count_user_messages_today,
    19	)
    20	from backend.services import attachment_service, gitlab_service
    21	from backend.services.generation_manager import manager as gen_manager
    22	
    23	router = APIRouter()
    24	
    25	
    26	class CreateChatBody(BaseModel):
    27	    title: str = "New Chat"
    28	    model: str = "eu.anthropic.claude-opus-4-6-v1"
    29	    knowledge_base_id: Optional[str] = None
    30	    linked_repo_id: Optional[str] = None
    31	    max_tokens: int = 4096
    32	    reasoning_budget: int = 0
    33	
    34	
    35	class UpdateChatBody(BaseModel):
    36	    title: Optional[str] = None
    37	    model: Optional[str] = None
    38	    max_tokens: Optional[int] = None
    39	    reasoning_budget: Optional[int] = None
    40	    knowledge_base_id: Optional[str] = None
    41	    linked_repo_id: Optional[str] = None
    42	
    43	
    44	class SendMessageBody(BaseModel):
    45	    content: str
    46	    model: Optional[str] = None
    47	    max_tokens: int = 4096
    48	    reasoning_budget: int = 0
    49	    knowledge_base_id: Optional[str] = None
    50	    attachment_ids: list[str] = []
    51	    web_search: bool = False
    52	
    53	
    54	class CommitFromChatBody(BaseModel):
    55	    branch: str
    56	    commit_message: str
    57	    files: list[dict]
    58	
    59	
    60	@router.get("")
    61	def list_chats(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
    62	    chats = db.query(Chat).filter(Chat.user_id == user.id).order_by(Chat.updated_at.desc()).all()
    63	    return [_chat_dict(c, db) for c in chats]
    64	
    65	
    66	@router.post("")
    67	def create_chat(body: CreateChatBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
    68	    perms = get_user_permissions(user.id, db)
    69	    max_chats = perms.get("max_chats", 0)
    70	    if max_chats > 0:
    71	        current_count = count_user_chats(user.id, db)
    72	        if current_count >= max_chats:
    73	            raise HTTPException(403, f"Chat limit reached ({max_chats}). Delete old chats or contact admin.")
    74	
    75	    if body.knowledge_base_id:
    76	        if not perms.get("can_use_knowledge_base"):
    77	            raise HTTPException(403, "Knowledge base access not enabled for your account.")
    78	
    79	    if body.linked_repo_id:
    80	        if not perms.get("can_use_gitlab"):
    81	            raise HTTPException(403, "GitLab access not enabled for your account.")
    82	
    83	    chat = Chat(
    84	        user_id=user.id, title=body.title,
    85	        model=check_model_allowed(user.id, body.model, db),
    86	        knowledge_base_id=body.knowledge_base_id or None,
    87	        linked_repo_id=body.linked_repo_id or None,
    88	        max_tokens=min(body.max_tokens, perms.get("max_tokens_cap", 4096)),
    89	        reasoning_budget=min(body.reasoning_budget, perms.get("max_reasoning_budget", 0)),
    90	    )
    91	    db.add(chat); db.commit(); db.refresh(chat)
    92	    return _chat_dict(chat, db)
    93	
    94	
    95	@router.get("/{chat_id}")
    96	def get_chat(chat_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
    97	    chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
    98	    if not chat: raise HTTPException(404, "Chat not found")
    99	    return _chat_dict(chat, db)
   100	
   101	
   102	@router.put("/{chat_id}")
   103	def update_chat(chat_id: str, body: UpdateChatBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
   104	    chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
   105	    if not chat: raise HTTPException(404)
   106	    perms = get_user_permissions(user.id, db)
   107	    if body.title is not None: chat.title = body.title
   108	    if body.model is not None: chat.model = check_model_allowed(user.id, body.model, db)
   109	    if body.max_tokens is not None: chat.max_tokens = min(body.max_tokens, perms.get("max_tokens_cap", 4096))
   110	    if body.reasoning_budget is not None: chat.reasoning_budget = min(body.reasoning_budget, perms.get("max_reasoning_budget", 0))
   111	    if body.knowledge_base_id is not None:
   112	        if body.knowledge_base_id and not perms.get("can_use_knowledge_base"):
   113	            raise HTTPException(403, "Knowledge base not enabled.")
   114	        chat.knowledge_base_id = body.knowledge_base_id or None
   115	    if body.linked_repo_id is not None:
   116	        if body.linked_repo_id and not perms.get("can_use_gitlab"):
   117	            raise HTTPException(403, "GitLab not enabled.")
   118	        chat.linked_repo_id = body.linked_repo_id or None
   119	    db.commit()
   120	    return _chat_dict(chat, db)
   121	
   122	
   123	@router.delete("/{chat_id}")
   124	def delete_chat(chat_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
   125	    chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
   126	    if not chat: raise HTTPException(404)
   127	    attachment_service.delete_chat_attachments(chat_id)
   128	    db.delete(chat); db.commit()
   129	    return {"ok": True}
   130	
   131	
   132	@router.get("/{chat_id}/messages")
   133	def get_messages(chat_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
   134	    chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
   135	    if not chat: raise HTTPException(404)
   136	    msgs = []
   137	    for m in chat.messages:
   138	        d = _msg_dict(m)
   139	        atts = db.query(ChatAttachment).filter(ChatAttachment.message_id == m.id).all()
   140	        d["attachments"] = [_att_brief(a) for a in atts]
   141	        msgs.append(d)
   142	    return msgs
   143	
   144	
   145	@router.get("/{chat_id}/generating")
   146	def check_generating(chat_id: str, user: User = Depends(get_current_user)):
   147	    return {"active": gen_manager.is_active(chat_id)}
   148	
   149	
   150	@router.get("/{chat_id}/stream")
   151	async def reconnect_stream(chat_id: str, user: User = Depends(get_current_user)):
   152	    if not gen_manager.is_active(chat_id):
   153	        async def empty():
   154	            yield _sse({"type": "done", "message_id": ""})
   155	        return StreamingResponse(empty(), media_type="text/event-stream")
   156	    async def generate():
   157	        async for event in gen_manager.stream_events(chat_id):
   158	            yield _sse(event)
   159	    return StreamingResponse(generate(), media_type="text/event-stream")
   160	
   161	
   162	@router.post("/{chat_id}/messages")
   163	async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
   164	    perms = get_user_permissions(user.id, db)
   165	
   166	    max_per_day = perms.get("max_messages_per_day", 0)
   167	    if max_per_day > 0:
   168	        today_count = count_user_messages_today(user.id, db)
   169	        if today_count >= max_per_day:
   170	            raise HTTPException(429, f"Daily message limit reached ({max_per_day}). Try again tomorrow.")
   171	
   172	    web_search = body.web_search
   173	    if web_search and not perms.get("can_use_web_search"):
   174	        web_search = False
   175	
   176	    if body.attachment_ids and not perms.get("can_use_attachments"):
   177	        raise HTTPException(403, "File attachments not enabled for your account.")
   178	
   179	    if body.attachment_ids:
   180	        max_att = perms.get("max_attachments_per_message", 5)
   181	        if len(body.attachment_ids) > max_att:
   182	            raise HTTPException(400, f"Too many attachments. Max {max_att} per message.")
   183	
   184	    model = check_model_allowed(user.id, body.model or "eu.anthropic.claude-opus-4-6-v1", db)
   185	    max_tokens = min(body.max_tokens, perms.get("max_tokens_cap", 4096))
   186	    reasoning_budget = min(body.reasoning_budget, perms.get("max_reasoning_budget", 0))
   187	
   188	    gen_manager.start(
   189	        chat_id=chat_id, user_id=user.id, content=body.content,
   190	        model=model, max_tokens=max_tokens, reasoning_budget=reasoning_budget,
   191	        knowledge_base_id=body.knowledge_base_id,
   192	        attachment_ids=body.attachment_ids,
   193	        web_search=web_search,
   194	    )
   195	    async def generate():
   196	        async for event in gen_manager.stream_events(chat_id):
   197	            yield _sse(event)
   198	    return StreamingResponse(generate(), media_type="text/event-stream")
   199	
   200	
   201	@router.post("/{chat_id}/commit")
   202	async def commit_from_chat(
   203	    chat_id: str,
   204	    body: CommitFromChatBody,
   205	    user: User = Depends(get_current_user),
   206	    db: Session = Depends(get_db),
   207	):
   208	    """
   209	    Commit files from chat to linked GitLab repo.
   210	    Auto-detects whether each file should be 'create' or 'update'
   211	    by checking the repo tree, so it never fails on wrong action type.
   212	    """
   213	    check_feature(user.id, "use_gitlab", db)
   214	
   215	    chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
   216	    if not chat:
   217	        raise HTTPException(404, "Chat not found")
   218	    if not chat.linked_repo_id:
   219	        raise HTTPException(400, "No repository linked")
   220	
   221	    repo = db.query(LinkedRepo).filter(LinkedRepo.id == chat.linked_repo_id).first()
   222	    if not repo:
   223	        raise HTTPException(404, "Linked repository not found")
   224	
   225	    settings = db.query(GitLabSettings).first()
   226	    if not settings or not settings.is_active:
   227	        raise HTTPException(400, "GitLab not configured")
   228	
   229	    # ── Fetch repo tree to know which files already exist ──
   230	    existing_paths = set()
   231	    try:
   232	        tree = await gitlab_service.get_tree(
   233	            settings.gitlab_url,
   234	            settings.gitlab_token,
   235	            repo.gitlab_project_id,
   236	            ref=body.branch,
   237	            recursive=True,
   238	        )
   239	        existing_paths = {
   240	            item["path"] for item in tree if item["type"] == "blob"
   241	        }
   242	    except Exception:
   243	        # If tree fetch fails (empty repo, network issue, etc.),
   244	        # we'll try all as "create" since we can't know what exists
   245	        pass
   246	
   247	    # ── Build actions with auto-detected create/update ──
   248	    actions = []
   249	    for f in body.files:
   250	        file_path = f.get("file_path", "")
   251	        content = f.get("content", "")
   252	        requested_action = f.get("action", "auto")
   253	
   254	        if not file_path or not content:
   255	            continue
   256	
   257	        file_exists = file_path in existing_paths
   258	
   259	        # Smart action resolution
   260	        if requested_action in ("auto", "upsert"):
   261	            # Auto-detect: update if exists, create if not
   262	            actual_action = "update" if file_exists else "create"
   263	        elif requested_action == "update" and not file_exists:
   264	            # User said update but file doesn't exist → create instead
   265	            actual_action = "create"
   266	        elif requested_action == "create" and file_exists:
   267	            # User said create but file already exists → update instead
   268	            actual_action = "update"
   269	        else:
   270	            actual_action = requested_action
   271	
   272	        actions.append({
   273	            "action": actual_action,
   274	            "file_path": file_path,
   275	            "content": content,
   276	        })
   277	
   278	    if not actions:
   279	        raise HTTPException(400, "No valid files to commit")
   280	
   281	    try:
   282	        result = await gitlab_service.commit_files(
   283	            settings.gitlab_url,
   284	            settings.gitlab_token,
   285	            repo.gitlab_project_id,
   286	            body.branch,
   287	            body.commit_message,
   288	            actions,
   289	        )
   290	        gen_manager.invalidate_repo_cache(repo.id)
   291	        return {
   292	            "ok": True,
   293	            "commit": result,
   294	            "files_committed": len(actions),
   295	        }
   296	    except gitlab_service.GitLabError as e:
   297	        raise HTTPException(e.status_code, f"Commit failed: {e.detail}")
   298	
   299	
   300	@router.post("/{chat_id}/refresh-repo")
   301	async def refresh_repo_context(chat_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
   302	    chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
   303	    if not chat or not chat.linked_repo_id: raise HTTPException(400, "No repo linked")
   304	    gen_manager.invalidate_repo_cache(chat.linked_repo_id)
   305	    return {"ok": True}
   306	
   307	
   308	def _sse(data):
   309	    return f"data: {json.dumps(data)}\n\n"
   310	
   311	
   312	def _chat_dict(c, db=None):
   313	    d = {
   314	        "id": c.id, "title": c.title, "model": c.model,
   315	        "knowledge_base_id": c.knowledge_base_id,
   316	        "linked_repo_id": c.linked_repo_id,
   317	        "max_tokens": c.max_tokens or 4096,
   318	        "reasoning_budget": c.reasoning_budget or 0,
   319	        "created_at": str(c.created_at),
   320	        "updated_at": str(c.updated_at),
   321	    }
   322	    if db and c.linked_repo_id:
   323	        repo = db.query(LinkedRepo).filter(LinkedRepo.id == c.linked_repo_id).first()
   324	        if repo:
   325	            d["linked_repo"] = {
   326	                "id": repo.id, "name": repo.name,
   327	                "path_with_namespace": repo.path_with_namespace,
   328	                "default_branch": repo.default_branch,
   329	                "web_url": repo.web_url,
   330	                "gitlab_project_id": repo.gitlab_project_id,
   331	                "map_status": repo.map_status,
   332	            }
   333	    return d
   334	
   335	
   336	def _msg_dict(m):
   337	    return {
   338	        "id": m.id, "role": m.role, "content": m.content,
   339	        "thinking_content": m.thinking_content,
   340	        "input_tokens": m.input_tokens, "output_tokens": m.output_tokens,
   341	        "created_at": str(m.created_at),
   342	    }
   343	
   344	
   345	def _att_brief(a):
   346	    return {
   347	        "id": a.id, "original_filename": a.original_filename,
   348	        "mime_type": a.mime_type, "file_type": a.file_type,
   349	        "file_size": a.file_size,
   350	    }│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [018]: backend/routes/chat_routes.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [019/69]: backend/routes/export_routes.py
│ LANGUAGE: python | LINES: 58 | SIZE: 1951 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	Export routes — PPTX and DOCX with permission checks.
     3	"""
     4	
     5	import re
     6	from pydantic import BaseModel
     7	from typing import Optional
     8	
     9	from fastapi import APIRouter, Depends
    10	from fastapi.responses import StreamingResponse
    11	import io
    12	
    13	from backend.database import get_db
    14	from backend.auth import get_current_user, check_feature
    15	from backend.models import User
    16	from backend.services.pptx_service import generate_pptx
    17	from backend.services.docx_service import generate_docx
    18	from sqlalchemy.orm import Session
    19	
    20	router = APIRouter()
    21	
    22	
    23	class ExportBody(BaseModel):
    24	    markdown: str
    25	    title: Optional[str] = None
    26	
    27	
    28	def _safe_filename(title: str, ext: str) -> str:
    29	    if not title or title == "New Chat":
    30	        return f"document.{ext}"
    31	    safe = re.sub(r'[^\w\s-]', '', title).strip()
    32	    safe = re.sub(r'\s+', '-', safe)[:60] or "document"
    33	    return f"{safe}.{ext}"
    34	
    35	
    36	@router.post("/pptx")
    37	def export_pptx(body: ExportBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
    38	    check_feature(user.id, "export_pptx", db)
    39	    title = body.title or "Presentation"
    40	    data = generate_pptx(body.markdown, title)
    41	    filename = _safe_filename(title, "pptx")
    42	    return StreamingResponse(
    43	        io.BytesIO(data),
    44	        media_type="application/vnd.openxmlformats-officedocument.presentationml.presentation",
    45	        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
    46	    )
    47	
    48	
    49	@router.post("/docx")
    50	def export_docx(body: ExportBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
    51	    check_feature(user.id, "export_docx", db)
    52	    title = body.title or "Document"
    53	    data = generate_docx(body.markdown, title)
    54	    filename = _safe_filename(title, "docx")
    55	    return StreamingResponse(
    56	        io.BytesIO(data),
    57	        media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
    58	        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
    59	    )│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [019]: backend/routes/export_routes.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [020/69]: backend/routes/files_routes.py
│ LANGUAGE: python | LINES: 62 | SIZE: 1664 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	Code extraction and file download helpers.
     3	"""
     4	
     5	import io
     6	import re
     7	import zipfile
     8	from typing import Optional
     9	from pydantic import BaseModel
    10	
    11	from fastapi import APIRouter
    12	from fastapi.responses import StreamingResponse
    13	
    14	from backend.services.code_extractor import extract_code_blocks
    15	
    16	router = APIRouter()
    17	
    18	
    19	class ExtractBody(BaseModel):
    20	    markdown: str
    21	    title: Optional[str] = None
    22	
    23	
    24	@router.post("/extract")
    25	def extract_files(body: ExtractBody):
    26	    blocks = extract_code_blocks(body.markdown)
    27	    return {"files": blocks}
    28	
    29	
    30	@router.post("/download-zip")
    31	def download_zip(body: ExtractBody):
    32	    blocks = extract_code_blocks(body.markdown)
    33	    if not blocks:
    34	        return {"error": "No code blocks found"}
    35	
    36	    # Keep LAST occurrence of each filename (latest version wins)
    37	    file_map: dict[str, str] = {}
    38	    for b in blocks:
    39	        file_map[b["filename"]] = b["code"]
    40	
    41	    if not file_map:
    42	        return {"error": "No code blocks found"}
    43	
    44	    # Build a safe zip filename from chat title
    45	    raw_title = (body.title or "").strip()
    46	    if not raw_title or raw_title == "New Chat":
    47	        safe_title = "code"
    48	    else:
    49	        safe_title = re.sub(r'[^\w\s-]', '', raw_title).strip()
    50	        safe_title = re.sub(r'[\s]+', '-', safe_title)[:60] or "code"
    51	    zip_filename = f"{safe_title}.zip"
    52	
    53	    buf = io.BytesIO()
    54	    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
    55	        for name, code in file_map.items():
    56	            zf.writestr(name, code)
    57	
    58	    buf.seek(0)
    59	    return StreamingResponse(
    60	        buf,
    61	        media_type="application/zip",
    62	        headers={"Content-Disposition": f'attachment; filename="{zip_filename}"'},
    63	    )│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [020]: backend/routes/files_routes.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [021/69]: backend/routes/gitlab_routes.py
│ LANGUAGE: python | LINES: 581 | SIZE: 21467 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	GitLab CE integration routes — superadmin only.
     3	Son of Anton v4.2.0
     4	"""
     5	
     6	import asyncio
     7	import json
     8	from datetime import datetime
     9	from typing import Optional
    10	
    11	from pydantic import BaseModel
    12	from fastapi import APIRouter, Depends, HTTPException, Query
    13	from sqlalchemy.orm import Session
    14	
    15	from backend.database import get_db
    16	from backend.models import User, GitLabSettings, LinkedRepo, PendingAction
    17	from backend.auth import require_superadmin
    18	from backend.services import gitlab_service, code_analyzer
    19	
    20	router = APIRouter()
    21	
    22	
    23	# ═══════════════════════════════════════════════════
    24	#  Request Bodies
    25	# ═══════════════════════════════════════════════════
    26	
    27	class SettingsBody(BaseModel):
    28	    gitlab_url: str
    29	    gitlab_token: str
    30	
    31	class CreateProjectBody(BaseModel):
    32	    name: str
    33	    description: str = ""
    34	    visibility: str = "private"
    35	
    36	class LinkRepoBody(BaseModel):
    37	    gitlab_project_id: int
    38	
    39	class CommitBody(BaseModel):
    40	    branch: str
    41	    commit_message: str
    42	    actions: list[dict]
    43	
    44	class SingleCommitBody(BaseModel):
    45	    branch: str
    46	    file_path: str
    47	    content: str
    48	    commit_message: str
    49	    action: str = "auto"
    50	
    51	class BranchBody(BaseModel):
    52	    branch_name: str
    53	    ref: str = "main"
    54	
    55	class MergeRequestBody(BaseModel):
    56	    source_branch: str
    57	    target_branch: str
    58	    title: str
    59	    description: str = ""
    60	
    61	class ActionBody(BaseModel):
    62	    linked_repo_id: str
    63	    action_type: str
    64	    title: str = ""
    65	    payload: str
    66	
    67	
    68	def _get_settings(db: Session) -> GitLabSettings:
    69	    s = db.query(GitLabSettings).first()
    70	    if not s or not s.is_active or not s.gitlab_url or not s.gitlab_token:
    71	        raise HTTPException(400, "GitLab not configured. Set up connection in GitLab Command Center.")
    72	    return s
    73	
    74	
    75	def _get_repo(repo_id: str, db: Session) -> LinkedRepo:
    76	    repo = db.query(LinkedRepo).filter(LinkedRepo.id == repo_id).first()
    77	    if not repo:
    78	        raise HTTPException(404, "Linked repo not found")
    79	    return repo
    80	
    81	
    82	# ═══════════════════════════════════════════════════
    83	#  Connection Settings
    84	# ═══════════════════════════════════════════════════
    85	
    86	@router.get("/settings")
    87	def get_settings(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
    88	    s = db.query(GitLabSettings).first()
    89	    if not s:
    90	        return {"gitlab_url": "", "gitlab_token_set": False, "is_active": False}
    91	    return {
    92	        "gitlab_url": s.gitlab_url,
    93	        "gitlab_token_set": bool(s.gitlab_token),
    94	        "is_active": s.is_active,
    95	        "updated_at": str(s.updated_at) if s.updated_at else None,
    96	    }
    97	
    98	
    99	@router.put("/settings")
   100	def update_settings(body: SettingsBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
   101	    s = db.query(GitLabSettings).first()
   102	    if not s:
   103	        s = GitLabSettings()
   104	        db.add(s)
   105	    s.gitlab_url = body.gitlab_url.rstrip("/")
   106	    s.gitlab_token = body.gitlab_token
   107	    s.is_active = True
   108	    db.commit()
   109	    return {"ok": True}
   110	
   111	
   112	@router.post("/test-connection")
   113	async def test_connection(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
   114	    s = db.query(GitLabSettings).first()
   115	    if not s or not s.gitlab_url or not s.gitlab_token:
   116	        raise HTTPException(400, "GitLab URL and token not configured")
   117	    try:
   118	        result = await gitlab_service.test_connection(s.gitlab_url, s.gitlab_token)
   119	        return result
   120	    except gitlab_service.GitLabError as e:
   121	        raise HTTPException(e.status_code, f"Connection failed: {e.detail}")
   122	    except Exception as e:
   123	        raise HTTPException(502, f"Cannot reach GitLab: {str(e)}")
   124	
   125	
   126	# ═══════════════════════════════════════════════════
   127	#  GitLab Projects (remote)
   128	# ═══════════════════════════════════════════════════
   129	
   130	@router.get("/projects")
   131	async def search_projects(
   132	    search: Optional[str] = Query(None),
   133	    owned: bool = Query(False),
   134	    admin: User = Depends(require_superadmin),
   135	    db: Session = Depends(get_db),
   136	):
   137	    s = _get_settings(db)
   138	    try:
   139	        projects = await gitlab_service.list_projects(s.gitlab_url, s.gitlab_token, search=search, owned=owned)
   140	        return projects
   141	    except gitlab_service.GitLabError as e:
   142	        raise HTTPException(e.status_code, e.detail)
   143	
   144	
   145	@router.post("/projects")
   146	async def create_project(body: CreateProjectBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
   147	    s = _get_settings(db)
   148	    try:
   149	        project = await gitlab_service.create_project(
   150	            s.gitlab_url, s.gitlab_token,
   151	            name=body.name, description=body.description, visibility=body.visibility,
   152	        )
   153	        return project
   154	    except gitlab_service.GitLabError as e:
   155	        raise HTTPException(e.status_code, e.detail)
   156	
   157	
   158	# ═══════════════════════════════════════════════════
   159	#  Linked Repos (local)
   160	# ═══════════════════════════════════════════════════
   161	
   162	@router.get("/repos")
   163	def list_repos(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
   164	    repos = db.query(LinkedRepo).order_by(LinkedRepo.created_at.desc()).all()
   165	    return [_repo_dict(r) for r in repos]
   166	
   167	
   168	@router.post("/repos")
   169	async def link_repo(body: LinkRepoBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
   170	    existing = db.query(LinkedRepo).filter(LinkedRepo.gitlab_project_id == body.gitlab_project_id).first()
   171	    if existing:
   172	        return _repo_dict(existing)
   173	    s = _get_settings(db)
   174	    try:
   175	        project = await gitlab_service.get_project(s.gitlab_url, s.gitlab_token, body.gitlab_project_id)
   176	    except gitlab_service.GitLabError as e:
   177	        raise HTTPException(e.status_code, f"Cannot fetch project: {e.detail}")
   178	    repo = LinkedRepo(
   179	        gitlab_project_id=project["id"],
   180	        name=project["name"],
   181	        path_with_namespace=project["path_with_namespace"],
   182	        default_branch=project.get("default_branch", "main"),
   183	        web_url=project.get("web_url", ""),
   184	        description=project.get("description", ""),
   185	        map_status="analyzing",
   186	    )
   187	    db.add(repo)
   188	    db.commit()
   189	    db.refresh(repo)
   190	
   191	    asyncio.create_task(_analyze_repo_background(
   192	        repo.id, s.gitlab_url, s.gitlab_token,
   193	        project["id"], project.get("default_branch", "main"),
   194	    ))
   195	
   196	    return _repo_dict(repo)
   197	
   198	
   199	@router.delete("/repos/{repo_id}")
   200	def unlink_repo(repo_id: str, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
   201	    repo = _get_repo(repo_id, db)
   202	    db.delete(repo)
   203	    db.commit()
   204	    return {"ok": True}
   205	
   206	
   207	# ═══════════════════════════════════════════════════
   208	#  Architecture Map
   209	# ═══════════════════════════════════════════════════
   210	
   211	async def _analyze_repo_background(repo_id: str, gitlab_url: str, gitlab_token: str, project_id: int, branch: str):
   212	    """Background task: load all files and generate architecture map."""
   213	    from backend.database import SessionLocal as BgSession
   214	    db = BgSession()
   215	    try:
   216	        repo = db.query(LinkedRepo).filter(LinkedRepo.id == repo_id).first()
   217	        if not repo:
   218	            return
   219	
   220	        repo.map_status = "analyzing"
   221	        db.commit()
   222	
   223	        result = await gitlab_service.load_project_files(
   224	            gitlab_url, gitlab_token, project_id, ref=branch,
   225	        )
   226	
   227	        files = result.get("files", [])
   228	        if not files:
   229	            repo.map_status = "failed"
   230	            repo.architecture_map = "[No files could be loaded for analysis]"
   231	            db.commit()
   232	            return
   233	
   234	        architecture_map = code_analyzer.analyze_codebase(files)
   235	
   236	        repo.architecture_map = architecture_map
   237	        repo.map_status = "ready"
   238	        repo.map_generated_at = datetime.utcnow()
   239	        db.commit()
   240	        print(f"  ✅ Architecture map generated for {repo.name} ({len(architecture_map)} chars)")
   241	
   242	    except Exception as e:
   243	        try:
   244	            repo = db.query(LinkedRepo).filter(LinkedRepo.id == repo_id).first()
   245	            if repo:
   246	                repo.map_status = "failed"
   247	                repo.architecture_map = f"[Analysis failed: {str(e)[:200]}]"
   248	                db.commit()
   249	        except Exception:
   250	            pass
   251	        print(f"  ❌ Architecture analysis failed for repo {repo_id}: {e}")
   252	    finally:
   253	        db.close()
   254	
   255	
   256	@router.post("/repos/{repo_id}/analyze")
   257	async def reanalyze_repo(repo_id: str, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
   258	    """Re-generate the architecture map for a linked repo."""
   259	    s = _get_settings(db)
   260	    repo = _get_repo(repo_id, db)
   261	
   262	    repo.map_status = "analyzing"
   263	    db.commit()
   264	
   265	    asyncio.create_task(_analyze_repo_background(
   266	        repo.id, s.gitlab_url, s.gitlab_token,
   267	        repo.gitlab_project_id, repo.default_branch,
   268	    ))
   269	
   270	    return {"ok": True, "status": "analyzing"}
   271	
   272	
   273	@router.get("/repos/{repo_id}/map")
   274	def get_repo_map(repo_id: str, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
   275	    """Get the architecture map for a linked repo."""
   276	    repo = _get_repo(repo_id, db)
   277	    return {
   278	        "map_status": repo.map_status or "none",
   279	        "map_generated_at": str(repo.map_generated_at) if repo.map_generated_at else None,
   280	        "architecture_map": repo.architecture_map or "",
   281	        "map_size": len(repo.architecture_map or ""),
   282	    }
   283	
   284	
   285	# ═══════════════════════════════════════════════════
   286	#  Repository Operations
   287	# ═══════════════════════════════════════════════════
   288	
   289	@router.get("/repos/{repo_id}/tree")
   290	async def get_tree(
   291	    repo_id: str,
   292	    path: str = Query(""),
   293	    ref: Optional[str] = Query(None),
   294	    admin: User = Depends(require_superadmin),
   295	    db: Session = Depends(get_db),
   296	):
   297	    s = _get_settings(db)
   298	    repo = _get_repo(repo_id, db)
   299	    branch = ref or repo.default_branch
   300	    try:
   301	        tree = await gitlab_service.get_tree(s.gitlab_url, s.gitlab_token, repo.gitlab_project_id, path=path, ref=branch)
   302	        return {"branch": branch, "path": path, "items": tree}
   303	    except gitlab_service.GitLabError as e:
   304	        raise HTTPException(e.status_code, e.detail)
   305	
   306	
   307	@router.get("/repos/{repo_id}/file")
   308	async def get_file(
   309	    repo_id: str,
   310	    path: str = Query(...),
   311	    ref: Optional[str] = Query(None),
   312	    admin: User = Depends(require_superadmin),
   313	    db: Session = Depends(get_db),
   314	):
   315	    s = _get_settings(db)
   316	    repo = _get_repo(repo_id, db)
   317	    branch = ref or repo.default_branch
   318	    try:
   319	        file_data = await gitlab_service.get_file_content(s.gitlab_url, s.gitlab_token, repo.gitlab_project_id, path, ref=branch)
   320	        return file_data
   321	    except gitlab_service.GitLabError as e:
   322	        raise HTTPException(e.status_code, e.detail)
   323	
   324	
   325	@router.get("/repos/{repo_id}/branches")
   326	async def get_branches(repo_id: str, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
   327	    s = _get_settings(db)
   328	    repo = _get_repo(repo_id, db)
   329	    try:
   330	        branches = await gitlab_service.list_branches(s.gitlab_url, s.gitlab_token, repo.gitlab_project_id)
   331	        return branches
   332	    except gitlab_service.GitLabError as e:
   333	        raise HTTPException(e.status_code, e.detail)
   334	
   335	
   336	@router.post("/repos/{repo_id}/branches")
   337	async def create_branch(repo_id: str, body: BranchBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
   338	    s = _get_settings(db)
   339	    repo = _get_repo(repo_id, db)
   340	    try:
   341	        result = await gitlab_service.create_branch(s.gitlab_url, s.gitlab_token, repo.gitlab_project_id, body.branch_name, body.ref)
   342	        return result
   343	    except gitlab_service.GitLabError as e:
   344	        raise HTTPException(e.status_code, e.detail)
   345	
   346	
   347	@router.post("/repos/{repo_id}/commit")
   348	async def commit_code(repo_id: str, body: CommitBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
   349	    """
   350	    Commit multiple files. Auto-detects create vs update per file.
   351	    """
   352	    s = _get_settings(db)
   353	    repo = _get_repo(repo_id, db)
   354	
   355	    # Fetch tree to know which files exist
   356	    existing_paths = set()
   357	    try:
   358	        tree = await gitlab_service.get_tree(
   359	            s.gitlab_url, s.gitlab_token, repo.gitlab_project_id,
   360	            ref=body.branch, recursive=True,
   361	        )
   362	        existing_paths = {item["path"] for item in tree if item["type"] == "blob"}
   363	    except Exception:
   364	        pass
   365	
   366	    resolved_actions = []
   367	    for a in body.actions:
   368	        file_path = a.get("file_path", "")
   369	        content = a.get("content", "")
   370	        requested = a.get("action", "auto")
   371	
   372	        if not file_path:
   373	            continue
   374	
   375	        file_exists = file_path in existing_paths
   376	
   377	        if requested in ("auto", "upsert"):
   378	            actual = "update" if file_exists else "create"
   379	        elif requested == "update" and not file_exists:
   380	            actual = "create"
   381	        elif requested == "create" and file_exists:
   382	            actual = "update"
   383	        else:
   384	            actual = requested
   385	
   386	        resolved_actions.append({
   387	            "action": actual,
   388	            "file_path": file_path,
   389	            "content": content,
   390	        })
   391	
   392	    if not resolved_actions:
   393	        raise HTTPException(400, "No valid files to commit")
   394	
   395	    try:
   396	        result = await gitlab_service.commit_files(
   397	            s.gitlab_url, s.gitlab_token, repo.gitlab_project_id,
   398	            body.branch, body.commit_message, resolved_actions,
   399	        )
   400	        return result
   401	    except gitlab_service.GitLabError as e:
   402	        raise HTTPException(e.status_code, e.detail)
   403	
   404	
   405	@router.post("/repos/{repo_id}/commit-single")
   406	async def commit_single(
   407	    repo_id: str,
   408	    body: SingleCommitBody,
   409	    admin: User = Depends(require_superadmin),
   410	    db: Session = Depends(get_db),
   411	):
   412	    """
   413	    Commit a single file. Auto-detects create vs update.
   414	    """
   415	    s = _get_settings(db)
   416	    repo = _get_repo(repo_id, db)
   417	
   418	    # Auto-detect whether file exists
   419	    action = body.action
   420	    if action in ("update", "create", "auto"):
   421	        try:
   422	            await gitlab_service.get_file_content(
   423	                s.gitlab_url, s.gitlab_token,
   424	                repo.gitlab_project_id, body.file_path, ref=body.branch,
   425	            )
   426	            file_exists = True
   427	        except gitlab_service.GitLabError:
   428	            file_exists = False
   429	
   430	        if action == "auto":
   431	            action = "update" if file_exists else "create"
   432	        elif action == "update" and not file_exists:
   433	            action = "create"
   434	        elif action == "create" and file_exists:
   435	            action = "update"
   436	
   437	    try:
   438	        result = await gitlab_service.commit_single_file(
   439	            s.gitlab_url, s.gitlab_token, repo.gitlab_project_id,
   440	            body.branch, body.file_path, body.content, body.commit_message, action,
   441	        )
   442	        return result
   443	    except gitlab_service.GitLabError as e:
   444	        raise HTTPException(e.status_code, e.detail)
   445	
   446	
   447	@router.post("/repos/{repo_id}/merge-request")
   448	async def create_mr(repo_id: str, body: MergeRequestBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
   449	    s = _get_settings(db)
   450	    repo = _get_repo(repo_id, db)
   451	    try:
   452	        result = await gitlab_service.create_merge_request(
   453	            s.gitlab_url, s.gitlab_token, repo.gitlab_project_id,
   454	            body.source_branch, body.target_branch, body.title, body.description,
   455	        )
   456	        return result
   457	    except gitlab_service.GitLabError as e:
   458	        raise HTTPException(e.status_code, e.detail)
   459	
   460	
   461	# ═══════════════════════════════════════════════════
   462	#  Pending Actions
   463	# ═══════════════════════════════════════════════════
   464	
   465	@router.get("/actions")
   466	def list_actions(
   467	    status: str = Query("pending"),
   468	    admin: User = Depends(require_superadmin),
   469	    db: Session = Depends(get_db),
   470	):
   471	    q = db.query(PendingAction).filter(PendingAction.status == status)
   472	    actions = q.order_by(PendingAction.created_at.desc()).limit(100).all()
   473	    return [_action_dict(a, db) for a in actions]
   474	
   475	
   476	@router.post("/actions")
   477	def create_action(body: ActionBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
   478	    repo = _get_repo(body.linked_repo_id, db)
   479	    action = PendingAction(
   480	        linked_repo_id=repo.id,
   481	        action_type=body.action_type,
   482	        title=body.title,
   483	        payload=body.payload,
   484	    )
   485	    db.add(action)
   486	    db.commit()
   487	    db.refresh(action)
   488	    return _action_dict(action, db)
   489	
   490	
   491	@router.post("/actions/{action_id}/approve")
   492	async def approve_action(action_id: str, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
   493	    action = db.query(PendingAction).filter(PendingAction.id == action_id).first()
   494	    if not action:
   495	        raise HTTPException(404)
   496	    if action.status != "pending":
   497	        raise HTTPException(400, f"Action already {action.status}")
   498	
   499	    s = _get_settings(db)
   500	    repo = _get_repo(action.linked_repo_id, db)
   501	    payload = json.loads(action.payload)
   502	
   503	    try:
   504	        if action.action_type == "commit":
   505	            result = await gitlab_service.commit_files(
   506	                s.gitlab_url, s.gitlab_token, repo.gitlab_project_id,
   507	                payload["branch"], payload["commit_message"], payload["actions"],
   508	            )
   509	            action.result_message = json.dumps(result)
   510	        elif action.action_type == "create_branch":
   511	            result = await gitlab_service.create_branch(
   512	                s.gitlab_url, s.gitlab_token, repo.gitlab_project_id,
   513	                payload["branch_name"], payload.get("ref", repo.default_branch),
   514	            )
   515	            action.result_message = json.dumps(result)
   516	        elif action.action_type == "create_mr":
   517	            result = await gitlab_service.create_merge_request(
   518	                s.gitlab_url, s.gitlab_token, repo.gitlab_project_id,
   519	                payload["source_branch"], payload["target_branch"],
   520	                payload["title"], payload.get("description", ""),
   521	            )
   522	            action.result_message = json.dumps(result)
   523	        else:
   524	            raise HTTPException(400, f"Unknown action type: {action.action_type}")
   525	
   526	        action.status = "approved"
   527	        action.resolved_at = datetime.utcnow()
   528	        db.commit()
   529	        return {"ok": True, "result": json.loads(action.result_message)}
   530	
   531	    except gitlab_service.GitLabError as e:
   532	        action.status = "rejected"
   533	        action.result_message = f"GitLab error: {e.detail}"
   534	        action.resolved_at = datetime.utcnow()
   535	        db.commit()
   536	        raise HTTPException(e.status_code, e.detail)
   537	
   538	
   539	@router.post("/actions/{action_id}/reject")
   540	def reject_action(action_id: str, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
   541	    action = db.query(PendingAction).filter(PendingAction.id == action_id).first()
   542	    if not action:
   543	        raise HTTPException(404)
   544	    action.status = "rejected"
   545	    action.resolved_at = datetime.utcnow()
   546	    db.commit()
   547	    return {"ok": True}
   548	
   549	
   550	# ═══════════════════════════════════════════════════
   551	#  Helpers
   552	# ═══════════════════════════════════════════════════
   553	
   554	def _repo_dict(r: LinkedRepo) -> dict:
   555	    return {
   556	        "id": r.id,
   557	        "gitlab_project_id": r.gitlab_project_id,
   558	        "name": r.name,
   559	        "path_with_namespace": r.path_with_namespace,
   560	        "default_branch": r.default_branch,
   561	        "web_url": r.web_url,
   562	        "description": r.description,
   563	        "map_status": r.map_status or "none",
   564	        "map_generated_at": str(r.map_generated_at) if r.map_generated_at else None,
   565	        "created_at": str(r.created_at),
   566	    }
   567	
   568	
   569	def _action_dict(a: PendingAction, db: Session) -> dict:
   570	    repo = db.query(LinkedRepo).filter(LinkedRepo.id == a.linked_repo_id).first()
   571	    return {
   572	        "id": a.id,
   573	        "linked_repo_id": a.linked_repo_id,
   574	        "repo_name": repo.name if repo else "?",
   575	        "action_type": a.action_type,
   576	        "title": a.title,
   577	        "payload": a.payload,
   578	        "status": a.status,
   579	        "result_message": a.result_message,
   580	        "created_at": str(a.created_at),
   581	        "resolved_at": str(a.resolved_at) if a.resolved_at else None,
   582	    }│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [021]: backend/routes/gitlab_routes.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [022/69]: backend/routes/knowledge_routes.py
│ LANGUAGE: python | LINES: 196 | SIZE: 8528 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	Knowledge base management — v4.2.0 with permission enforcement.
     3	"""
     4	
     5	from pydantic import BaseModel
     6	from typing import Optional
     7	
     8	from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
     9	from sqlalchemy.orm import Session
    10	
    11	from backend.database import get_db
    12	from backend.models import User, KnowledgeBase, KnowledgeDocument
    13	from backend.auth import (
    14	    get_current_user, get_user_permissions, check_feature,
    15	    count_user_knowledge_bases, count_kb_documents,
    16	)
    17	from backend.services import rag_service
    18	from backend.config import MAX_UPLOAD_BYTES
    19	
    20	router = APIRouter()
    21	
    22	
    23	class CreateKBBody(BaseModel):
    24	    name: str
    25	    description: str = ""
    26	
    27	
    28	class UpdateKBBody(BaseModel):
    29	    name: Optional[str] = None
    30	    description: Optional[str] = None
    31	
    32	
    33	@router.get("")
    34	def list_knowledge_bases(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
    35	    kbs = db.query(KnowledgeBase).filter(
    36	        (KnowledgeBase.user_id == user.id) | (KnowledgeBase.user_id == None)
    37	    ).order_by(KnowledgeBase.created_at.desc()).all()
    38	    return [_kb_dict(kb) for kb in kbs]
    39	
    40	
    41	@router.post("")
    42	def create_kb(body: CreateKBBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
    43	    check_feature(user.id, "use_knowledge_base", db)
    44	    perms = get_user_permissions(user.id, db)
    45	    max_kbs = perms.get("max_knowledge_bases", 0)
    46	    if max_kbs > 0:
    47	        current = count_user_knowledge_bases(user.id, db)
    48	        if current >= max_kbs:
    49	            raise HTTPException(403, f"Knowledge base limit reached ({max_kbs}). Delete old ones or contact admin.")
    50	
    51	    kb = KnowledgeBase(user_id=user.id, name=body.name, description=body.description)
    52	    db.add(kb)
    53	    db.commit()
    54	    db.refresh(kb)
    55	    rag_service.create_collection(kb.id)
    56	    return _kb_dict(kb)
    57	
    58	
    59	@router.get("/{kb_id}")
    60	def get_kb(kb_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
    61	    kb = _get_kb(kb_id, user, db)
    62	    docs = (
    63	        db.query(KnowledgeDocument)
    64	        .filter(KnowledgeDocument.knowledge_base_id == kb_id)
    65	        .order_by(KnowledgeDocument.created_at.desc())
    66	        .all()
    67	    )
    68	    return {**_kb_dict(kb), "documents": [_doc_dict(d) for d in docs]}
    69	
    70	
    71	@router.put("/{kb_id}")
    72	def update_kb(kb_id: str, body: UpdateKBBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
    73	    kb = _get_kb(kb_id, user, db)
    74	    if body.name is not None: kb.name = body.name
    75	    if body.description is not None: kb.description = body.description
    76	    db.commit(); db.refresh(kb)
    77	    return _kb_dict(kb)
    78	
    79	
    80	@router.delete("/{kb_id}")
    81	def delete_kb(kb_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
    82	    kb = _get_kb(kb_id, user, db)
    83	    try: rag_service.delete_collection(kb_id)
    84	    except Exception: pass
    85	    db.query(KnowledgeDocument).filter(KnowledgeDocument.knowledge_base_id == kb_id).delete()
    86	    db.delete(kb); db.commit()
    87	    return {"ok": True}
    88	
    89	
    90	@router.get("/{kb_id}/documents")
    91	def list_documents(kb_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
    92	    _get_kb(kb_id, user, db)
    93	    docs = db.query(KnowledgeDocument).filter(KnowledgeDocument.knowledge_base_id == kb_id).order_by(KnowledgeDocument.created_at.desc()).all()
    94	    return [_doc_dict(d) for d in docs]
    95	
    96	
    97	@router.delete("/{kb_id}/documents/{doc_id}")
    98	def delete_document(kb_id: str, doc_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
    99	    kb = _get_kb(kb_id, user, db)
   100	    doc = db.query(KnowledgeDocument).filter(KnowledgeDocument.id == doc_id, KnowledgeDocument.knowledge_base_id == kb_id).first()
   101	    if not doc: raise HTTPException(404, "Document not found")
   102	    try: removed_count = rag_service.delete_document_chunks(kb_id, doc.filename)
   103	    except Exception: removed_count = 0
   104	    kb.document_count = max((kb.document_count or 0) - 1, 0)
   105	    kb.chunk_count = max((kb.chunk_count or 0) - (doc.chunk_count or 0), 0)
   106	    estimated_chars = (doc.chunk_count or 0) * 2000
   107	    kb.total_characters = max((kb.total_characters or 0) - estimated_chars, 0)
   108	    db.delete(doc); db.commit()
   109	    return {"ok": True, "chunks_removed": removed_count, "document_id": doc_id, "filename": doc.filename}
   110	
   111	
   112	@router.post("/{kb_id}/upload")
   113	async def upload_documents(kb_id: str, files: list[UploadFile] = File(...), user: User = Depends(get_current_user), db: Session = Depends(get_db)):
   114	    check_feature(user.id, "use_knowledge_base", db)
   115	    kb = _get_kb(kb_id, user, db)
   116	    perms = get_user_permissions(user.id, db)
   117	
   118	    # Enforce max documents per KB
   119	    max_docs = perms.get("max_documents_per_kb", 0)
   120	    if max_docs > 0:
   121	        current_docs = count_kb_documents(kb_id, db)
   122	        if current_docs + len(files) > max_docs:
   123	            raise HTTPException(403, f"Document limit for this KB: {max_docs}. Currently: {current_docs}. Trying to add: {len(files)}.")
   124	
   125	    results = []; total_new_docs = 0; total_new_chunks = 0; total_new_chars = 0
   126	    for file in files:
   127	        filename = file.filename or "document.txt"
   128	        try:
   129	            content_bytes = await file.read()
   130	            if len(content_bytes) > MAX_UPLOAD_BYTES:
   131	                results.append({"filename": filename, "error": f"File too large. Max {MAX_UPLOAD_BYTES // 1024 // 1024}MB"})
   132	                continue
   133	            text = _extract_text(filename, content_bytes)
   134	            if not text.strip():
   135	                results.append({"filename": filename, "error": "Could not extract text from file"})
   136	                continue
   137	            chunks = _chunk_text(text, chunk_size=3000, overlap=300)
   138	            rag_service.add_documents(collection_id=kb_id, documents=chunks, metadatas=[{"filename": filename, "chunk_index": i} for i in range(len(chunks))])
   139	            doc = KnowledgeDocument(knowledge_base_id=kb_id, filename=filename, file_size=len(content_bytes), chunk_count=len(chunks))
   140	            db.add(doc)
   141	            total_new_docs += 1; total_new_chunks += len(chunks); total_new_chars += len(text)
   142	            results.append({"filename": filename, "chunks_added": len(chunks), "characters": len(text), "estimated_tokens": len(text) // 4})
   143	        except HTTPException as e:
   144	            results.append({"filename": filename, "error": str(e.detail)})
   145	        except Exception as e:
   146	            results.append({"filename": filename, "error": str(e)})
   147	
   148	    kb.document_count = (kb.document_count or 0) + total_new_docs
   149	    kb.chunk_count = (kb.chunk_count or 0) + total_new_chunks
   150	    kb.total_characters = (kb.total_characters or 0) + total_new_chars
   151	    db.commit()
   152	    return {"files": results, "total_files": len(results), "total_chunks_added": total_new_chunks, "total_characters": total_new_chars}
   153	
   154	
   155	def _get_kb(kb_id, user, db):
   156	    kb = db.query(KnowledgeBase).filter(KnowledgeBase.id == kb_id).first()
   157	    if not kb: raise HTTPException(404, "Knowledge base not found")
   158	    if kb.user_id and kb.user_id != user.id and user.role != "superadmin":
   159	        raise HTTPException(403, "Access denied")
   160	    return kb
   161	
   162	
   163	def _extract_text(filename, content):
   164	    lower = filename.lower()
   165	    if lower.endswith(".pdf"):
   166	        try:
   167	            from PyPDF2 import PdfReader
   168	            import io
   169	            reader = PdfReader(io.BytesIO(content))
   170	            return "\n\n".join(page.extract_text() or "" for page in reader.pages)
   171	        except Exception as e:
   172	            raise HTTPException(400, f"PDF extraction failed: {e}")
   173	    else:
   174	        try: return content.decode("utf-8")
   175	        except UnicodeDecodeError: return content.decode("latin-1")
   176	
   177	
   178	def _chunk_text(text, chunk_size=3000, overlap=300):
   179	    chunks = []; start = 0
   180	    while start < len(text):
   181	        end = start + chunk_size
   182	        if end < len(text):
   183	            for sep in ["\n\n", "\n", ". ", " "]:
   184	                pos = text.rfind(sep, start + chunk_size // 2, end)
   185	                if pos > start: end = pos + len(sep); break
   186	        chunk = text[start:end].strip()
   187	        if chunk: chunks.append(chunk)
   188	        start = end - overlap if end < len(text) else end
   189	    return chunks
   190	
   191	
   192	def _kb_dict(kb):
   193	    return {"id": kb.id, "name": kb.name, "description": kb.description or "", "document_count": kb.document_count or 0, "chunk_count": kb.chunk_count or 0, "total_characters": kb.total_characters or 0, "estimated_tokens": (kb.total_characters or 0) // 4, "created_at": str(kb.created_at)}
   194	
   195	
   196	def _doc_dict(d):
   197	    return {"id": d.id, "filename": d.filename, "file_size": d.file_size or 0, "chunk_count": d.chunk_count or 0, "created_at": str(d.created_at)}│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [022]: backend/routes/knowledge_routes.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [023/69]: backend/routes/messages_patch.py
│ LANGUAGE: python | LINES: 127 | SIZE: 3838 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	SON OF ANTON — INTEGRATION PATCH FOR YOUR EXISTING MESSAGE ROUTE
     3	
     4	This is NOT a standalone file. These are the functions and code blocks
     5	you need to ADD to your existing message-sending route (the one that
     6	handles POST /chats/{chat_id}/messages and streams SSE back).
     7	
     8	--- STEP 1: Add these imports at the top of your messages route file ---
     9	"""
    10	
    11	# Add to your imports:
    12	import os
    13	from ..services.file_processor import build_content_blocks_for_attachments
    14	
    15	UPLOAD_DIR = os.getenv("UPLOAD_DIR", "uploads")
    16	
    17	
    18	"""
    19	--- STEP 2: In your request body model/parsing, add attachment_ids ---
    20	
    21	Your existing body probably looks like:
    22	  { content, model, max_tokens, reasoning_budget, knowledge_base_id }
    23	
    24	Add:
    25	  attachment_ids: list[str] = []
    26	
    27	So it becomes:
    28	  { content, model, max_tokens, reasoning_budget, knowledge_base_id, attachment_ids }
    29	"""
    30	
    31	
    32	"""
    33	--- STEP 3: Where you build the Claude messages array, replace the simple
    34	    text content with a content block array when attachments exist ---
    35	
    36	BEFORE (you probably have something like):
    37	    user_message_content = body.content
    38	    # or
    39	    messages_for_claude.append({"role": "user", "content": body.content})
    40	
    41	AFTER (replace with):
    42	"""
    43	
    44	
    45	async def build_user_content(db, body_content: str, attachment_ids: list, chat_id: str):
    46	    """
    47	    Build Claude content blocks for a user message.
    48	    If there are attachments, returns a list of content blocks.
    49	    If no attachments, returns the plain text string (backward compatible).
    50	    """
    51	    if not attachment_ids:
    52	        return [{"text": body_content}]
    53	
    54	    import uuid as uuid_mod
    55	
    56	    # Fetch attachment records
    57	    att_uuids = [uuid_mod.UUID(aid) for aid in attachment_ids]
    58	    attachments = await db.fetch(
    59	        """
    60	        SELECT id, filename, original_filename, mime_type, file_size, media_type, storage_path
    61	        FROM attachments
    62	        WHERE id = ANY($1) AND chat_id = $2
    63	        """,
    64	        att_uuids,
    65	        uuid_mod.UUID(chat_id),
    66	    )
    67	    attachments = [dict(a) for a in attachments]
    68	
    69	    # Build content blocks: text first, then file blocks
    70	    content_blocks = []
    71	
    72	    # Add the text message
    73	    if body_content.strip():
    74	        content_blocks.append({"text": body_content})
    75	
    76	    # Add file content blocks
    77	    file_blocks = build_content_blocks_for_attachments(attachments, UPLOAD_DIR)
    78	    content_blocks.extend(file_blocks)
    79	
    80	    # If no text was provided, add a default prompt
    81	    if not body_content.strip():
    82	        content_blocks.insert(0, {"text": "Please describe and analyze the attached file(s)."})
    83	
    84	    # Link attachments to the message (do after message is saved)
    85	    return content_blocks
    86	
    87	
    88	"""
    89	--- STEP 4: In your Claude API call, use the content blocks ---
    90	
    91	Instead of:
    92	    {"role": "user", "content": "user text here"}
    93	
    94	Use:
    95	    {"role": "user", "content": content_blocks}
    96	
    97	Where content_blocks comes from build_user_content() above.
    98	
    99	--- STEP 5: After saving the user message to DB, link attachments ---
   100	"""
   101	
   102	
   103	async def link_attachments_to_message(db, attachment_ids: list, message_id):
   104	    """Call this after inserting the user message into your messages table."""
   105	    import uuid as uuid_mod
   106	    if attachment_ids:
   107	        await db.execute(
   108	            "UPDATE attachments SET message_id = $1 WHERE id = ANY($2)",
   109	            message_id,
   110	            [uuid_mod.UUID(aid) for aid in attachment_ids],
   111	        )
   112	
   113	
   114	"""
   115	--- STEP 6: Register the attachments router in your main app file ---
   116	
   117	In your main.py or app.py, add:
   118	
   119	    from .routes.attachments import router as attachments_router
   120	    app.include_router(attachments_router, prefix="/api")
   121	
   122	--- STEP 7: Also serve uploaded files statically (optional, for image previews) ---
   123	
   124	    from fastapi.staticfiles import StaticFiles
   125	    app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
   126	
   127	--- DONE. That's it for the backend. ---
   128	"""│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [023]: backend/routes/messages_patch.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [024/69]: backend/seed.py
│ LANGUAGE: python | LINES: 70 | SIZE: 2764 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	Seed superadmin user, default permissions template, and app settings.
     3	"""
     4	
     5	from backend.database import SessionLocal
     6	from backend.models import User, UserPermissions, AppSettings
     7	from backend.auth import hash_password
     8	from backend.config import (
     9	    SUPERADMIN_PASSWORD, SUPERADMIN_PERMISSIONS, DEFAULT_PERMISSIONS,
    10	    PERMISSION_FIELDS, REGISTRATION_ENABLED_DEFAULT,
    11	)
    12	
    13	
    14	def seed_superadmin():
    15	    db = SessionLocal()
    16	    try:
    17	        existing = db.query(User).filter(User.username == "superadmin").first()
    18	        if not existing:
    19	            user = User(
    20	                username="superadmin",
    21	                email="admin@sonofanton.local",
    22	                password_hash=hash_password(SUPERADMIN_PASSWORD),
    23	                role="superadmin",
    24	                quota_tokens_monthly=999_999_999,
    25	            )
    26	            db.add(user)
    27	            db.commit()
    28	            db.refresh(user)
    29	            print(f"  Created superadmin (password: {SUPERADMIN_PASSWORD})")
    30	
    31	            perms = UserPermissions(user_id=user.id)
    32	            for field in PERMISSION_FIELDS:
    33	                if hasattr(perms, field):
    34	                    setattr(perms, field, SUPERADMIN_PERMISSIONS.get(field))
    35	            db.add(perms)
    36	            db.commit()
    37	            print("  Created superadmin permissions")
    38	        else:
    39	            sp = db.query(UserPermissions).filter(UserPermissions.user_id == existing.id).first()
    40	            if not sp:
    41	                perms = UserPermissions(user_id=existing.id)
    42	                for field in PERMISSION_FIELDS:
    43	                    if hasattr(perms, field):
    44	                        setattr(perms, field, SUPERADMIN_PERMISSIONS.get(field))
    45	                db.add(perms)
    46	                db.commit()
    47	                print("  Created superadmin permissions (existing user)")
    48	
    49	        # Create/update defaults template
    50	        defaults = db.query(UserPermissions).filter(UserPermissions.user_id == "__defaults__").first()
    51	        if not defaults:
    52	            defaults = UserPermissions(user_id="__defaults__")
    53	            for field in PERMISSION_FIELDS:
    54	                if hasattr(defaults, field):
    55	                    setattr(defaults, field, DEFAULT_PERMISSIONS.get(field))
    56	            db.add(defaults)
    57	            db.commit()
    58	            print("  Created default permissions template")
    59	
    60	        # Seed app settings if missing
    61	        app_settings = db.query(AppSettings).first()
    62	        if not app_settings:
    63	            app_settings = AppSettings(registration_enabled=REGISTRATION_ENABLED_DEFAULT)
    64	            db.add(app_settings)
    65	            db.commit()
    66	            print(f"  Created app settings (registration: {REGISTRATION_ENABLED_DEFAULT})")
    67	
    68	    except Exception as e:
    69	        print(f"  Seed error: {e}")
    70	    finally:
    71	        db.close()│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [024]: backend/seed.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [025/69]: backend/services/attachment_service.py
│ LANGUAGE: python | LINES: 260 | SIZE: 8733 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	Attachment processing service.
     3	Handles images, videos (frame extraction), PDFs, and text files.
     4	"""
     5	
     6	import os
     7	import io
     8	import base64
     9	import shutil
    10	import subprocess
    11	import tempfile
    12	import mimetypes
    13	from pathlib import Path
    14	from uuid import uuid4
    15	from typing import Optional
    16	
    17	from backend import config
    18	
    19	os.makedirs(config.ATTACHMENT_PATH, exist_ok=True)
    20	
    21	IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"}
    22	VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv", ".m4v"}
    23	PDF_EXTENSIONS = {".pdf"}
    24	TEXT_EXTENSIONS = {
    25	    ".txt", ".md", ".py", ".js", ".ts", ".jsx", ".tsx", ".cs", ".java",
    26	    ".cpp", ".c", ".h", ".hpp", ".go", ".rs", ".rb", ".php", ".swift",
    27	    ".kt", ".lua", ".gd", ".html", ".css", ".scss", ".json", ".yaml",
    28	    ".yml", ".xml", ".toml", ".ini", ".cfg", ".conf", ".sh", ".bash",
    29	    ".sql", ".r", ".dart", ".vue", ".svelte", ".csv", ".log", ".env",
    30	}
    31	
    32	IMAGE_MIMES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
    33	VIDEO_MIMES = {"video/mp4", "video/quicktime", "video/x-msvideo", "video/webm"}
    34	
    35	
    36	def classify_file(filename, mime):
    37	    ext = Path(filename).suffix.lower()
    38	    if ext in IMAGE_EXTENSIONS or mime in IMAGE_MIMES:
    39	        return "image"
    40	    if ext in VIDEO_EXTENSIONS or mime in VIDEO_MIMES:
    41	        return "video"
    42	    if ext in PDF_EXTENSIONS or mime == "application/pdf":
    43	        return "document"
    44	    return "text"
    45	
    46	
    47	def get_mime_type(filename, content_type=None):
    48	    if content_type and content_type != "application/octet-stream":
    49	        return content_type
    50	    mime, _ = mimetypes.guess_type(filename)
    51	    return mime or "application/octet-stream"
    52	
    53	
    54	def save_attachment(chat_id, filename, content, content_type=None):
    55	    mime = get_mime_type(filename, content_type)
    56	    file_type = classify_file(filename, mime)
    57	    attachment_id = str(uuid4())
    58	
    59	    chat_dir = os.path.join(config.ATTACHMENT_PATH, chat_id)
    60	    os.makedirs(chat_dir, exist_ok=True)
    61	
    62	    safe_name = Path(filename).name.replace(" ", "_")
    63	    stored_name = f"{attachment_id}_{safe_name}"
    64	    storage_path = os.path.join(chat_dir, stored_name)
    65	
    66	    with open(storage_path, "wb") as f:
    67	        f.write(content)
    68	
    69	    text_extract = None
    70	    if file_type == "text":
    71	        text_extract = _extract_text_content(storage_path)
    72	    elif file_type == "document":
    73	        text_extract = _extract_pdf_text(storage_path)
    74	
    75	    return {
    76	        "id": attachment_id,
    77	        "filename": stored_name,
    78	        "original_filename": filename,
    79	        "mime_type": mime,
    80	        "file_type": file_type,
    81	        "file_size": len(content),
    82	        "storage_path": storage_path,
    83	        "text_extract": text_extract,
    84	    }
    85	
    86	
    87	def delete_attachment_file(storage_path):
    88	    try:
    89	        if os.path.exists(storage_path):
    90	            os.remove(storage_path)
    91	    except Exception:
    92	        pass
    93	
    94	
    95	def delete_chat_attachments(chat_id):
    96	    chat_dir = os.path.join(config.ATTACHMENT_PATH, chat_id)
    97	    if os.path.isdir(chat_dir):
    98	        shutil.rmtree(chat_dir, ignore_errors=True)
    99	
   100	
   101	def build_claude_content_blocks(attachments):
   102	    blocks = []
   103	    for att in attachments:
   104	        try:
   105	            result = _process_single_attachment(att)
   106	            if isinstance(result, list):
   107	                blocks.extend(result)
   108	            elif result:
   109	                blocks.append(result)
   110	        except Exception as e:
   111	            blocks.append({
   112	                "type": "text",
   113	                "text": f"[Failed to process {att.original_filename}: {str(e)}]",
   114	            })
   115	    return blocks
   116	
   117	
   118	def _process_single_attachment(att):
   119	    if att.file_type == "image":
   120	        return _build_image_block(att)
   121	    elif att.file_type == "video":
   122	        return _build_video_blocks(att)
   123	    elif att.file_type == "document":
   124	        return _build_document_block(att)
   125	    elif att.file_type == "text":
   126	        return _build_text_block(att)
   127	    return None
   128	
   129	
   130	def _build_image_block(att):
   131	    data = _read_and_resize_image(att.storage_path, att.mime_type)
   132	    mime = att.mime_type if att.mime_type in IMAGE_MIMES else "image/jpeg"
   133	    return {
   134	        "type": "image",
   135	        "source": {"type": "base64", "media_type": mime, "data": data},
   136	    }
   137	
   138	
   139	def _build_video_blocks(att):
   140	    frames = _extract_video_frames(att.storage_path)
   141	    if not frames:
   142	        return [{"type": "text", "text": f"[Video: {att.original_filename} - could not extract frames]"}]
   143	    blocks = [{"type": "text", "text": f"[Video: {att.original_filename} - {len(frames)} frames extracted]"}]
   144	    for frame_b64 in frames:
   145	        blocks.append({
   146	            "type": "image",
   147	            "source": {"type": "base64", "media_type": "image/jpeg", "data": frame_b64},
   148	        })
   149	    return blocks
   150	
   151	
   152	def _build_document_block(att):
   153	    if att.mime_type == "application/pdf":
   154	        with open(att.storage_path, "rb") as f:
   155	            data = base64.b64encode(f.read()).decode("utf-8")
   156	        return {
   157	            "type": "document",
   158	            "source": {"type": "base64", "media_type": "application/pdf", "data": data},
   159	        }
   160	    return _build_text_block(att)
   161	
   162	
   163	def _build_text_block(att):
   164	    text = att.text_extract or _extract_text_content(att.storage_path)
   165	    if not text:
   166	        text = f"[Could not extract text from {att.original_filename}]"
   167	    return {
   168	        "type": "text",
   169	        "text": f"--- File: {att.original_filename} ---\n{text}\n--- End of {att.original_filename} ---",
   170	    }
   171	
   172	
   173	def _read_and_resize_image(path, mime_type):
   174	    try:
   175	        from PIL import Image
   176	        img = Image.open(path)
   177	        if img.mode in ("RGBA", "LA", "P"):
   178	            bg = Image.new("RGB", img.size, (255, 255, 255))
   179	            if img.mode == "P":
   180	                img = img.convert("RGBA")
   181	            bg.paste(img, mask=img.split()[-1] if "A" in img.mode else None)
   182	            img = bg
   183	        elif img.mode != "RGB":
   184	            img = img.convert("RGB")
   185	        mx = config.MAX_IMAGE_DIMENSION
   186	        if img.width > mx or img.height > mx:
   187	            ratio = min(mx / img.width, mx / img.height)
   188	            img = img.resize((int(img.width * ratio), int(img.height * ratio)), Image.LANCZOS)
   189	        buf = io.BytesIO()
   190	        fmt = "PNG" if mime_type == "image/png" else "JPEG"
   191	        kwargs = {"quality": 85} if fmt == "JPEG" else {}
   192	        img.save(buf, format=fmt, **kwargs)
   193	        return base64.b64encode(buf.getvalue()).decode("utf-8")
   194	    except ImportError:
   195	        with open(path, "rb") as f:
   196	            return base64.b64encode(f.read()).decode("utf-8")
   197	    except Exception:
   198	        with open(path, "rb") as f:
   199	            return base64.b64encode(f.read()).decode("utf-8")
   200	
   201	
   202	def _extract_video_frames(video_path):
   203	    if not shutil.which("ffmpeg") or not shutil.which("ffprobe"):
   204	        return []
   205	    frames = []
   206	    try:
   207	        result = subprocess.run(
   208	            ["ffprobe", "-v", "error", "-show_entries", "format=duration",
   209	             "-of", "default=noprint_wrappers=1:nokey=1", video_path],
   210	            capture_output=True, text=True, timeout=30,
   211	        )
   212	        duration = float(result.stdout.strip() or "0")
   213	        if duration <= 0:
   214	            return []
   215	        max_frames = config.MAX_VIDEO_FRAMES
   216	        with tempfile.TemporaryDirectory() as tmpdir:
   217	            interval = duration / (max_frames + 1)
   218	            for i in range(max_frames):
   219	                ts = interval * (i + 1)
   220	                out = os.path.join(tmpdir, f"frame_{i}.jpg")
   221	                subprocess.run(
   222	                    ["ffmpeg", "-ss", str(ts), "-i", video_path, "-vframes", "1",
   223	                     "-vf", f"scale='min({config.MAX_IMAGE_DIMENSION},iw)':'min({config.MAX_IMAGE_DIMENSION},ih)':force_original_aspect_ratio=decrease",
   224	                     "-q:v", "3", out],
   225	                    capture_output=True, timeout=30,
   226	                )
   227	                if os.path.exists(out) and os.path.getsize(out) > 0:
   228	                    with open(out, "rb") as f:
   229	                        frames.append(base64.b64encode(f.read()).decode("utf-8"))
   230	    except Exception:
   231	        pass
   232	    return frames
   233	
   234	
   235	def _extract_text_content(path):
   236	    try:
   237	        with open(path, "r", encoding="utf-8") as f:
   238	            return f.read(500_000)
   239	    except UnicodeDecodeError:
   240	        try:
   241	            with open(path, "r", encoding="latin-1") as f:
   242	                return f.read(500_000)
   243	        except Exception:
   244	            return None
   245	    except Exception:
   246	        return None
   247	
   248	
   249	def _extract_pdf_text(path):
   250	    try:
   251	        from PyPDF2 import PdfReader
   252	        reader = PdfReader(path)
   253	        pages = []
   254	        for page in reader.pages[:100]:
   255	            text = page.extract_text()
   256	            if text:
   257	                pages.append(text)
   258	        return "\n\n".join(pages) if pages else None
   259	    except Exception:
   260	        return None
│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [025]: backend/services/attachment_service.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [026/69]: backend/services/bedrock_service.py
│ LANGUAGE: python | LINES: 242 | SIZE: 8418 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	AWS Bedrock integration — streaming and non-streaming.
     3	Uses Bearer-token auth and parses the AWS binary event-stream wire format.
     4	"""
     5	
     6	import struct
     7	import json
     8	import base64
     9	from typing import AsyncIterator, Optional
    10	from urllib.parse import quote
    11	
    12	import httpx
    13	
    14	from backend import config
    15	
    16	_client: Optional[httpx.AsyncClient] = None
    17	
    18	
    19	def _get_client() -> httpx.AsyncClient:
    20	    global _client
    21	    if _client is None or _client.is_closed:
    22	        _client = httpx.AsyncClient(
    23	            timeout=httpx.Timeout(connect=30.0, read=600.0, write=30.0, pool=60.0),
    24	        )
    25	    return _client
    26	
    27	
    28	async def close_http_client():
    29	    global _client
    30	    if _client and not _client.is_closed:
    31	        await _client.aclose()
    32	        _client = None
    33	
    34	
    35	# ══════════════════════════════════════════════════════════════
    36	# AWS Event-Stream Binary Parser
    37	# ══════════════════════════════════════════════════════════════
    38	#
    39	# Message layout (big-endian):
    40	#   [total_length : 4B]
    41	#   [headers_length : 4B]
    42	#   [prelude_crc : 4B]
    43	#   [headers : headers_length B]
    44	#   [payload : total_length - headers_length - 16 B]
    45	#   [message_crc : 4B]
    46	# ══════════════════════════════════════════════════════════════
    47	
    48	_MIN_MSG = 16          # 4+4+4 (prelude) + 0 (hdrs) + 0 (payload) + 4 (crc)
    49	_PRELUDE = 12          # total_length + headers_length + prelude_crc
    50	
    51	
    52	def _drain_messages(buf: bytes) -> tuple[list[dict], bytes]:
    53	    """Return (parsed_events, remaining_buffer)."""
    54	    events: list[dict] = []
    55	    offset = 0
    56	
    57	    while len(buf) - offset >= _MIN_MSG:
    58	        total_length = struct.unpack("!I", buf[offset : offset + 4])[0]
    59	        if total_length < _MIN_MSG or len(buf) - offset < total_length:
    60	            break                               # incomplete message
    61	
    62	        hdrs_len = struct.unpack("!I", buf[offset + 4 : offset + 8])[0]
    63	        h_start = offset + _PRELUDE
    64	        h_end = h_start + hdrs_len
    65	        p_start = h_end
    66	        p_end = offset + total_length - 4       # before message_crc
    67	
    68	        headers = _decode_headers(buf[h_start:h_end])
    69	        payload = buf[p_start:p_end]
    70	        events.append({"headers": headers, "payload": payload})
    71	        offset += total_length
    72	
    73	    return events, buf[offset:]
    74	
    75	
    76	def _decode_headers(data: bytes) -> dict:
    77	    headers: dict = {}
    78	    o = 0
    79	    dlen = len(data)
    80	
    81	    while o < dlen:
    82	        if o + 1 > dlen:
    83	            break
    84	        name_len = data[o]; o += 1
    85	        if o + name_len > dlen:
    86	            break
    87	        name = data[o : o + name_len].decode("utf-8"); o += name_len
    88	        if o + 1 > dlen:
    89	            break
    90	        htype = data[o]; o += 1
    91	
    92	        if htype == 7:                          # string
    93	            if o + 2 > dlen: break
    94	            vl = struct.unpack("!H", data[o : o + 2])[0]; o += 2
    95	            if o + vl > dlen: break
    96	            headers[name] = data[o : o + vl].decode("utf-8"); o += vl
    97	        elif htype == 6:                        # byte-array
    98	            if o + 2 > dlen: break
    99	            vl = struct.unpack("!H", data[o : o + 2])[0]; o += 2
   100	            o += vl
   101	        elif htype in (0, 1):                   # bool true / false
   102	            headers[name] = (htype == 0)
   103	        elif htype == 2: o += 1                 # byte
   104	        elif htype == 3: o += 2                 # short
   105	        elif htype == 4:                        # int
   106	            if o + 4 <= dlen:
   107	                headers[name] = struct.unpack("!i", data[o : o + 4])[0]
   108	            o += 4
   109	        elif htype == 5: o += 8                 # long
   110	        elif htype == 8: o += 8                 # timestamp
   111	        elif htype == 9: o += 16                # uuid
   112	        else:
   113	            break                               # unknown — stop parsing
   114	
   115	    return headers
   116	
   117	
   118	def _unwrap_event(raw: dict) -> Optional[dict]:
   119	    """Turn one AWS event-stream frame into an Anthropic streaming dict."""
   120	    h = raw["headers"]
   121	    msg_type = h.get(":message-type", "")
   122	
   123	    # ── Exception frames ──────────────────────────
   124	    if msg_type == "exception":
   125	        text = raw["payload"].decode("utf-8", errors="replace")
   126	        return {"type": "error", "error": {"message": text}}
   127	
   128	    if msg_type != "event":
   129	        return None
   130	
   131	    # ── Normal event — payload is JSON with "bytes" key ───
   132	    try:
   133	        wrapper = json.loads(raw["payload"])
   134	    except (json.JSONDecodeError, UnicodeDecodeError):
   135	        return None
   136	
   137	    b64 = wrapper.get("bytes")
   138	    if not b64:
   139	        return None
   140	
   141	    try:
   142	        inner = base64.b64decode(b64)
   143	        return json.loads(inner)
   144	    except Exception:
   145	        return None
   146	
   147	
   148	# ══════════════════════════════════════════════════════════════
   149	# Public API
   150	# ══════════════════════════════════════════════════════════════
   151	
   152	async def stream_response(
   153	    *,
   154	    messages: list[dict],
   155	    system_prompt: str,
   156	    model_id: str,
   157	    max_tokens: int = 4096,
   158	    thinking_config: Optional[dict] = None,
   159	) -> AsyncIterator[dict]:
   160	    """
   161	    Yields Anthropic streaming event dicts:
   162	      message_start, content_block_start, content_block_delta,
   163	      content_block_stop, message_delta, message_stop
   164	    """
   165	    client = _get_client()
   166	
   167	    safe_model = quote(model_id, safe="")
   168	    url = f"{config.BEDROCK_ENDPOINT}/model/{safe_model}/invoke-with-response-stream"
   169	
   170	    body: dict = {
   171	        "anthropic_version": "bedrock-2023-05-31",
   172	        "max_tokens": max_tokens,
   173	        "system": system_prompt,
   174	        "messages": messages,
   175	    }
   176	
   177	    if thinking_config and thinking_config.get("enabled"):
   178	        budget = thinking_config.get("budget_tokens", 8000)
   179	        body["thinking"] = {
   180	            "type": "enabled",
   181	            "budget_tokens": budget,
   182	        }
   183	        # max_tokens must cover thinking + response
   184	        if body["max_tokens"] < budget + 1024:
   185	            body["max_tokens"] = budget + 1024
   186	
   187	    headers = {
   188	        "Authorization": f"Bearer {config.BEDROCK_API_KEY}",
   189	        "Content-Type": "application/json",
   190	        "Accept": "application/vnd.amazon.eventstream",
   191	    }
   192	
   193	    async with client.stream("POST", url, json=body, headers=headers) as resp:
   194	        if resp.status_code != 200:
   195	            err = await resp.aread()
   196	            raise RuntimeError(
   197	                f"Bedrock HTTP {resp.status_code}: {err.decode('utf-8', errors='replace')[:500]}"
   198	            )
   199	
   200	        buf = b""
   201	        async for chunk in resp.aiter_bytes():
   202	            buf += chunk
   203	            events, buf = _drain_messages(buf)
   204	            for raw_evt in events:
   205	                parsed = _unwrap_event(raw_evt)
   206	                if parsed:
   207	                    if parsed.get("type") == "error":
   208	                        raise RuntimeError(
   209	                            parsed.get("error", {}).get("message", "Unknown Bedrock error")
   210	                        )
   211	                    yield parsed
   212	
   213	
   214	async def invoke_model_simple(
   215	    model_id: str,
   216	    prompt: str,
   217	    max_tokens: int = 120,
   218	) -> str:
   219	    """Non-streaming invoke — used for quick tasks like title generation."""
   220	    client = _get_client()
   221	
   222	    safe_model = quote(model_id, safe="")
   223	    url = f"{config.BEDROCK_ENDPOINT}/model/{safe_model}/invoke"
   224	
   225	    body = {
   226	        "anthropic_version": "bedrock-2023-05-31",
   227	        "max_tokens": max_tokens,
   228	        "messages": [{"role": "user", "content": prompt}],
   229	    }
   230	
   231	    headers = {
   232	        "Authorization": f"Bearer {config.BEDROCK_API_KEY}",
   233	        "Content-Type": "application/json",
   234	        "Accept": "application/json",
   235	    }
   236	
   237	    resp = await client.post(url, json=body, headers=headers)
   238	    if resp.status_code != 200:
   239	        raise RuntimeError(f"Bedrock invoke error {resp.status_code}: {resp.text[:300]}")
   240	
   241	    data = resp.json()
   242	    parts = data.get("content", [])
   243	    return " ".join(p.get("text", "") for p in parts if p.get("type") == "text")│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [026]: backend/services/bedrock_service.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [027/69]: backend/services/code_analyzer.py
│ LANGUAGE: python | LINES: 656 | SIZE: 23990 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	Codebase Architecture Analyzer — generates a structural mindmap
     3	of any repository by analyzing imports, exports, routes, and
     4	cross-boundary connections.
     5	
     6	Works with: Python, JS/TS/JSX/TSX, C#, Go, Rust, Java, Ruby, PHP
     7	"""
     8	
     9	import re
    10	from collections import defaultdict
    11	from typing import Optional
    12	
    13	# ═══════════════════════════════════════════════════
    14	#  Language-specific parsers
    15	# ═══════════════════════════════════════════════════
    16	
    17	def _parse_python(path: str, content: str) -> dict:
    18	    """Extract imports, definitions, and routes from Python files."""
    19	    imports = []
    20	    definitions = []
    21	    routes = []
    22	    decorators = []
    23	
    24	    for line in content.split("\n"):
    25	        stripped = line.strip()
    26	
    27	        # Imports
    28	        m = re.match(r'^from\s+([\w.]+)\s+import\s+(.+)', stripped)
    29	        if m:
    30	            module = m.group(1)
    31	            names = [n.strip().split(" as ")[0].strip() for n in m.group(2).split(",")]
    32	            imports.append({"module": module, "names": names})
    33	            continue
    34	
    35	        m = re.match(r'^import\s+([\w.]+)', stripped)
    36	        if m:
    37	            imports.append({"module": m.group(1), "names": []})
    38	            continue
    39	
    40	        # Decorators (accumulate for next def/class)
    41	        m = re.match(r'^@(\w+)\.(get|post|put|delete|patch|websocket)\s*\(\s*["\']([^"\']*)', stripped)
    42	        if m:
    43	            method = m.group(2).upper()
    44	            route_path = m.group(3)
    45	            decorators.append({"method": method, "path": route_path})
    46	            continue
    47	
    48	        m = re.match(r'^@(\w+)\.route\s*\(\s*["\']([^"\']*)', stripped)
    49	        if m:
    50	            decorators.append({"method": "ROUTE", "path": m.group(2)})
    51	            continue
    52	
    53	        # Function defs
    54	        m = re.match(r'^(?:async\s+)?def\s+(\w+)\s*\(', stripped)
    55	        if m:
    56	            name = m.group(1)
    57	            if not name.startswith("_") or name.startswith("__"):
    58	                defn = {"type": "function", "name": name}
    59	                definitions.append(defn)
    60	                if decorators:
    61	                    for d in decorators:
    62	                        routes.append({
    63	                            "method": d["method"],
    64	                            "path": d["path"],
    65	                            "handler": name,
    66	                            "file": path,
    67	                        })
    68	            decorators = []
    69	            continue
    70	
    71	        # Class defs
    72	        m = re.match(r'^class\s+(\w+)\s*[\(:]', stripped)
    73	        if m:
    74	            definitions.append({"type": "class", "name": m.group(1)})
    75	            decorators = []
    76	            continue
    77	
    78	        # Clear decorators on non-matching lines
    79	        if stripped and not stripped.startswith("@") and not stripped.startswith("#"):
    80	            decorators = []
    81	
    82	    return {"imports": imports, "definitions": definitions, "routes": routes}
    83	
    84	
    85	def _parse_javascript(path: str, content: str) -> dict:
    86	    """Extract imports, exports, routes, and API calls from JS/TS files."""
    87	    imports = []
    88	    exports = []
    89	    api_calls = []
    90	    routes = []
    91	
    92	    for line in content.split("\n"):
    93	        stripped = line.strip()
    94	
    95	        # ES6 imports
    96	        m = re.match(r'^import\s+(?:{([^}]+)}|(\w+))\s+from\s+["\']([^"\']+)', stripped)
    97	        if m:
    98	            names = []
    99	            if m.group(1):
   100	                names = [n.strip().split(" as ")[0].strip() for n in m.group(1).split(",")]
   101	            elif m.group(2):
   102	                names = [m.group(2)]
   103	            imports.append({"module": m.group(3), "names": names})
   104	            continue
   105	
   106	        # import default + named
   107	        m = re.match(r'^import\s+(\w+)\s*,\s*{([^}]+)}\s+from\s+["\']([^"\']+)', stripped)
   108	        if m:
   109	            names = [m.group(1)] + [n.strip().split(" as ")[0].strip() for n in m.group(2).split(",")]
   110	            imports.append({"module": m.group(3), "names": names})
   111	            continue
   112	
   113	        # import * as
   114	        m = re.match(r'^import\s+\*\s+as\s+(\w+)\s+from\s+["\']([^"\']+)', stripped)
   115	        if m:
   116	            imports.append({"module": m.group(2), "names": [f"* as {m.group(1)}"]})
   117	            continue
   118	
   119	        # require
   120	        m = re.search(r'require\s*\(\s*["\']([^"\']+)', stripped)
   121	        if m and not stripped.startswith("//"):
   122	            imports.append({"module": m.group(1), "names": []})
   123	
   124	        # Exports
   125	        m = re.match(r'^export\s+(?:default\s+)?(?:async\s+)?function\s+(\w+)', stripped)
   126	        if m:
   127	            exports.append({"type": "function", "name": m.group(1)})
   128	            continue
   129	
   130	        m = re.match(r'^export\s+(?:default\s+)?(?:const|let|var)\s+(\w+)', stripped)
   131	        if m:
   132	            exports.append({"type": "const", "name": m.group(1)})
   133	            continue
   134	
   135	        m = re.match(r'^export\s+default\s+(?:class\s+)?(\w+)', stripped)
   136	        if m:
   137	            exports.append({"type": "default", "name": m.group(1)})
   138	            continue
   139	
   140	        # API calls (fetch)
   141	        for fm in re.finditer(r'fetch\s*\(\s*[`"\']([^`"\']*(?:/api/[^`"\']*)?)[`"\']', stripped):
   142	            url = fm.group(1)
   143	            if "/api/" in url or url.startswith("/"):
   144	                api_calls.append(url)
   145	
   146	        for fm in re.finditer(r'fetch\s*\(\s*`\$\{[^}]*\}(/[^`]*)`', stripped):
   147	            api_calls.append(fm.group(1))
   148	
   149	        # Express routes
   150	        m = re.match(r'(?:app|router)\.(get|post|put|delete|patch)\s*\(\s*["\']([^"\']+)', stripped)
   151	        if m:
   152	            routes.append({
   153	                "method": m.group(1).upper(),
   154	                "path": m.group(2),
   155	                "file": path,
   156	            })
   157	
   158	    return {"imports": imports, "exports": exports, "api_calls": api_calls, "routes": routes}
   159	
   160	
   161	def _parse_csharp(path: str, content: str) -> dict:
   162	    """Extract basic structure from C# files."""
   163	    imports = []
   164	    definitions = []
   165	    routes = []
   166	
   167	    for line in content.split("\n"):
   168	        stripped = line.strip()
   169	
   170	        m = re.match(r'^using\s+([\w.]+)\s*;', stripped)
   171	        if m:
   172	            imports.append({"module": m.group(1), "names": []})
   173	            continue
   174	
   175	        m = re.match(r'^(?:public|private|protected|internal|static|\s)*class\s+(\w+)', stripped)
   176	        if m:
   177	            definitions.append({"type": "class", "name": m.group(1)})
   178	            continue
   179	
   180	        m = re.match(r'^(?:public|private|protected|internal|static|\s)*interface\s+(\w+)', stripped)
   181	        if m:
   182	            definitions.append({"type": "interface", "name": m.group(1)})
   183	            continue
   184	
   185	        # ASP.NET routes
   186	        m = re.search(r'\[Http(Get|Post|Put|Delete|Patch)\s*\(\s*"([^"]*)"', stripped)
   187	        if m:
   188	            routes.append({"method": m.group(1).upper(), "path": m.group(2), "file": path})
   189	            continue
   190	
   191	        m = re.search(r'\[Route\s*\(\s*"([^"]*)"', stripped)
   192	        if m:
   193	            routes.append({"method": "ROUTE", "path": m.group(1), "file": path})
   194	
   195	    return {"imports": imports, "definitions": definitions, "routes": routes}
   196	
   197	
   198	def _parse_generic(path: str, content: str) -> dict:
   199	    """Fallback: extract function/class patterns from any language."""
   200	    definitions = []
   201	    imports = []
   202	
   203	    for line in content.split("\n"):
   204	        stripped = line.strip()
   205	
   206	        # Go imports
   207	        m = re.match(r'^import\s+"([^"]+)"', stripped)
   208	        if m:
   209	            imports.append({"module": m.group(1), "names": []})
   210	
   211	        # Go/Rust function defs
   212	        m = re.match(r'^(?:pub\s+)?fn\s+(\w+)', stripped)
   213	        if m:
   214	            definitions.append({"type": "function", "name": m.group(1)})
   215	            continue
   216	
   217	        m = re.match(r'^func\s+(?:\([^)]+\)\s+)?(\w+)', stripped)
   218	        if m:
   219	            definitions.append({"type": "function", "name": m.group(1)})
   220	            continue
   221	
   222	        # Go/Rust struct/type
   223	        m = re.match(r'^(?:pub\s+)?(?:type|struct)\s+(\w+)', stripped)
   224	        if m:
   225	            definitions.append({"type": "struct", "name": m.group(1)})
   226	
   227	    return {"imports": imports, "definitions": definitions}
   228	
   229	
   230	# ═══════════════════════════════════════════════════
   231	#  File parser dispatcher
   232	# ═══════════════════════════════════════════════════
   233	
   234	def _parse_file(path: str, content: str) -> dict:
   235	    """Parse a file based on its extension."""
   236	    lower = path.lower()
   237	    if lower.endswith((".py",)):
   238	        return _parse_python(path, content)
   239	    elif lower.endswith((".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".vue", ".svelte")):
   240	        return _parse_javascript(path, content)
   241	    elif lower.endswith((".cs",)):
   242	        return _parse_csharp(path, content)
   243	    else:
   244	        return _parse_generic(path, content)
   245	
   246	
   247	# ═══════════════════════════════════════════════════
   248	#  Resolve import paths to actual files
   249	# ═══════════════════════════════════════════════════
   250	
   251	def _resolve_import(importing_file: str, module: str, all_paths: set[str]) -> Optional[str]:
   252	    """Try to resolve an import module string to an actual file path."""
   253	    if not module:
   254	        return None
   255	
   256	    # Direct path match
   257	    for ext in ["", ".py", ".js", ".ts", ".jsx", ".tsx", ".cs", ".go", ".rs"]:
   258	        candidate = module.replace(".", "/") + ext
   259	        if candidate in all_paths:
   260	            return candidate
   261	
   262	    # Relative imports for Python (from .module import ...)
   263	    if module.startswith("."):
   264	        dir_parts = importing_file.rsplit("/", 1)
   265	        base_dir = dir_parts[0] if len(dir_parts) > 1 else ""
   266	        rel = module.lstrip(".")
   267	        for ext in [".py", ""]:
   268	            candidate = f"{base_dir}/{rel.replace('.', '/')}{ext}"
   269	            if candidate in all_paths:
   270	                return candidate
   271	            # __init__.py
   272	            candidate = f"{base_dir}/{rel.replace('.', '/')}/__init__.py"
   273	            if candidate in all_paths:
   274	                return candidate
   275	
   276	    # JS relative imports
   277	    if module.startswith("./") or module.startswith("../"):
   278	        dir_parts = importing_file.rsplit("/", 1)
   279	        base_dir = dir_parts[0] if len(dir_parts) > 1 else ""
   280	        if module.startswith("./"):
   281	            rel = module[2:]
   282	        else:
   283	            # Go up directories
   284	            parts = base_dir.split("/")
   285	            ups = module.count("../")
   286	            base = "/".join(parts[:-ups]) if ups < len(parts) else ""
   287	            rel = module.replace("../", "")
   288	            base_dir = base
   289	
   290	        for ext in ["", ".js", ".jsx", ".ts", ".tsx", "/index.js", "/index.tsx", "/index.ts"]:
   291	            candidate = f"{base_dir}/{rel}{ext}" if base_dir else f"{rel}{ext}"
   292	            if candidate in all_paths:
   293	                return candidate
   294	
   295	    # Backend-style imports (backend.models → backend/models.py)
   296	    for ext in [".py"]:
   297	        candidate = module.replace(".", "/") + ext
   298	        if candidate in all_paths:
   299	            return candidate
   300	
   301	    return None
   302	
   303	
   304	# ═══════════════════════════════════════════════════
   305	#  Main analyzer
   306	# ═══════════════════════════════════════════════════
   307	
   308	MAX_MAP_CHARS = 40_000  # ~10K tokens max for the map
   309	
   310	
   311	def analyze_codebase(files: list[dict]) -> str:
   312	    """
   313	    Analyze a list of {path, content} dicts and produce
   314	    an architecture mindmap string.
   315	    """
   316	    all_paths = {f["path"] for f in files}
   317	    file_data = {}  # path → parsed data
   318	    all_routes = []
   319	    all_api_calls = []
   320	    import_graph = defaultdict(set)       # file → set of files it imports
   321	    imported_by = defaultdict(set)         # file → set of files that import it
   322	    definitions_map = defaultdict(list)    # file → list of definitions
   323	    exports_map = defaultdict(list)        # file → list of exports
   324	
   325	    # ── Pass 1: Parse all files ──
   326	    for f in files:
   327	        path = f["path"]
   328	        content = f.get("content", "")
   329	        if not content or content.startswith("["):
   330	            continue
   331	
   332	        parsed = _parse_file(path, content)
   333	        file_data[path] = parsed
   334	
   335	        # Collect definitions
   336	        for d in parsed.get("definitions", []):
   337	            definitions_map[path].append(d)
   338	
   339	        for e in parsed.get("exports", []):
   340	            exports_map[path].append(e)
   341	
   342	        # Collect routes
   343	        for r in parsed.get("routes", []):
   344	            r["file"] = path
   345	            all_routes.append(r)
   346	
   347	        # Collect API calls
   348	        for call in parsed.get("api_calls", []):
   349	            all_api_calls.append({"file": path, "url": call})
   350	
   351	    # ── Pass 2: Resolve imports → build dependency graph ──
   352	    for path, parsed in file_data.items():
   353	        for imp in parsed.get("imports", []):
   354	            target = _resolve_import(path, imp["module"], all_paths)
   355	            if target and target != path:
   356	                import_graph[path].add(target)
   357	                imported_by[target].add(path)
   358	
   359	    # ── Pass 3: Detect project structure ──
   360	    lang_counts = defaultdict(int)
   361	    dir_categories = defaultdict(set)
   362	
   363	    for path in all_paths:
   364	        ext = ""
   365	        if "." in path.rsplit("/", 1)[-1]:
   366	            ext = "." + path.rsplit(".", 1)[-1].lower()
   367	
   368	        lang_map = {
   369	            ".py": "Python", ".js": "JavaScript", ".ts": "TypeScript",
   370	            ".jsx": "React JSX", ".tsx": "React TSX", ".cs": "C#",
   371	            ".java": "Java", ".go": "Go", ".rs": "Rust", ".rb": "Ruby",
   372	            ".php": "PHP", ".vue": "Vue", ".svelte": "Svelte",
   373	            ".html": "HTML", ".css": "CSS", ".scss": "SCSS",
   374	            ".sql": "SQL", ".sh": "Shell",
   375	        }
   376	        if ext in lang_map:
   377	            lang_counts[lang_map[ext]] += 1
   378	
   379	        top_dir = path.split("/")[0] if "/" in path else "(root)"
   380	        dir_categories[top_dir].add(path)
   381	
   382	    # Detect frameworks
   383	    frameworks = []
   384	    all_content_lower = " ".join(
   385	        f.get("content", "")[:500].lower() for f in files[:50]
   386	    )
   387	    framework_signals = {
   388	        "FastAPI": ["fastapi", "from fastapi"],
   389	        "Flask": ["from flask"],
   390	        "Django": ["from django", "django.conf"],
   391	        "Express": ["express()", "require('express')"],
   392	        "React": ["from 'react'", 'from "react"', "react-dom"],
   393	        "Vue": ["createapp", "vue"],
   394	        "Next.js": ["next/", "getserversideprops"],
   395	        "Unity": ["monobehaviour", "unityengine"],
   396	        "ASP.NET": ["microsoft.aspnetcore", "iactionresult"],
   397	        "SQLAlchemy": ["sqlalchemy", "declarative_base"],
   398	        "Prisma": ["@prisma/client"],
   399	    }
   400	    for fw, signals in framework_signals.items():
   401	        for sig in signals:
   402	            if sig in all_content_lower:
   403	                frameworks.append(fw)
   404	                break
   405	
   406	    # ── Pass 4: Identify hot files (most referenced) ──
   407	    hot_files = sorted(imported_by.items(), key=lambda x: -len(x[1]))[:20]
   408	
   409	    # ── Pass 5: Cross-boundary flow detection ──
   410	    flows = _detect_cross_boundary_flows(all_routes, all_api_calls, import_graph, definitions_map)
   411	
   412	    # ── Pass 6: Build the map ──
   413	    return _format_architecture_map(
   414	        all_paths=all_paths,
   415	        lang_counts=lang_counts,
   416	        frameworks=frameworks,
   417	        dir_categories=dir_categories,
   418	        all_routes=all_routes,
   419	        all_api_calls=all_api_calls,
   420	        import_graph=import_graph,
   421	        imported_by=imported_by,
   422	        definitions_map=definitions_map,
   423	        exports_map=exports_map,
   424	        hot_files=hot_files,
   425	        flows=flows,
   426	    )
   427	
   428	
   429	def _detect_cross_boundary_flows(
   430	    routes: list, api_calls: list,
   431	    import_graph: dict, definitions_map: dict,
   432	) -> list[str]:
   433	    """Detect end-to-end flows across frontend/backend boundaries."""
   434	    flows = []
   435	
   436	    # Match API calls to routes
   437	    route_map = {}
   438	    for r in routes:
   439	        key = r.get("path", "").rstrip("/")
   440	        if key:
   441	            route_map[key] = r
   442	
   443	    for call in api_calls:
   444	        url = call["url"]
   445	        # Clean URL patterns
   446	        clean = re.sub(r'\$\{[^}]+\}', '{param}', url)
   447	        clean = clean.rstrip("/")
   448	
   449	        matched_route = None
   450	        for route_path, route_info in route_map.items():
   451	            # Simple match — exact or parameterized
   452	            route_clean = re.sub(r'\{[^}]+\}', '{param}', route_path)
   453	            if clean == route_clean or clean.endswith(route_clean):
   454	                matched_route = route_info
   455	                break
   456	
   457	        if matched_route:
   458	            flows.append(
   459	                f"  {call['file']} → {matched_route.get('method', '?')} {matched_route.get('path', '?')} → {matched_route.get('file', '?')}:{matched_route.get('handler', '?')}()"
   460	            )
   461	
   462	    return flows[:50]  # Cap flows
   463	
   464	
   465	def _format_architecture_map(
   466	    all_paths, lang_counts, frameworks, dir_categories,
   467	    all_routes, all_api_calls, import_graph, imported_by,
   468	    definitions_map, exports_map, hot_files, flows,
   469	) -> str:
   470	    """Format the analysis into a compact, AI-readable string."""
   471	    lines = []
   472	    lines.append("╔══════════════════════════════════════════╗")
   473	    lines.append("║     CODEBASE ARCHITECTURE MINDMAP        ║")
   474	    lines.append("╚══════════════════════════════════════════╝")
   475	    lines.append("")
   476	
   477	    # ── Project Overview ──
   478	    lines.append("PROJECT OVERVIEW:")
   479	    lines.append(f"  Total source files: {len(all_paths)}")
   480	    if lang_counts:
   481	        top_langs = sorted(lang_counts.items(), key=lambda x: -x[1])[:8]
   482	        lines.append(f"  Languages: {', '.join(f'{l} ({c})' for l, c in top_langs)}")
   483	    if frameworks:
   484	        lines.append(f"  Frameworks: {', '.join(sorted(set(frameworks)))}")
   485	    lines.append("")
   486	
   487	    # ── Directory Structure ──
   488	    lines.append("DIRECTORY STRUCTURE:")
   489	    for dir_name in sorted(dir_categories.keys()):
   490	        count = len(dir_categories[dir_name])
   491	        lines.append(f"  {dir_name}/ ({count} files)")
   492	    lines.append("")
   493	
   494	    # ── API Routes ──
   495	    if all_routes:
   496	        lines.append("API ENDPOINTS:")
   497	        # Group by file
   498	        routes_by_file = defaultdict(list)
   499	        for r in all_routes:
   500	            routes_by_file[r.get("file", "?")].append(r)
   501	        for rfile in sorted(routes_by_file.keys()):
   502	            lines.append(f"  [{rfile}]")
   503	            for r in routes_by_file[rfile]:
   504	                handler = r.get("handler", "")
   505	                handler_str = f" → {handler}()" if handler else ""
   506	                lines.append(f"    {r.get('method', '?'):6s} {r.get('path', '?')}{handler_str}")
   507	        lines.append("")
   508	
   509	    # ── Frontend → Backend Connections ──
   510	    if flows:
   511	        lines.append("CROSS-BOUNDARY FLOWS (Frontend → Backend):")
   512	        for flow in flows:
   513	            lines.append(flow)
   514	        lines.append("")
   515	
   516	    # ── Hot Files (most imported) ──
   517	    if hot_files:
   518	        lines.append("HOT FILES (most referenced by other files):")
   519	        for path, importers in hot_files:
   520	            if len(importers) >= 2:
   521	                lines.append(f"  {path} ← imported by {len(importers)} files")
   522	        lines.append("")
   523	
   524	    # ── Dependency Graph (compact) ──
   525	    if import_graph:
   526	        lines.append("DEPENDENCY GRAPH (file → its dependencies):")
   527	        # Show only files with 2+ deps, sorted by dep count
   528	        sorted_deps = sorted(import_graph.items(), key=lambda x: -len(x[1]))
   529	        shown = 0
   530	        for path, deps in sorted_deps:
   531	            if shown >= 60:
   532	                break
   533	            dep_names = sorted(deps)[:15]
   534	            extra = f" (+{len(deps) - 15} more)" if len(deps) > 15 else ""
   535	            short_deps = [d.rsplit("/", 1)[-1] for d in dep_names]
   536	            lines.append(f"  {path}")
   537	            lines.append(f"    → {', '.join(short_deps)}{extra}")
   538	            shown += 1
   539	        lines.append("")
   540	
   541	    # ── Key Definitions ──
   542	    if definitions_map:
   543	        lines.append("KEY DEFINITIONS:")
   544	        for path in sorted(definitions_map.keys()):
   545	            defs = definitions_map[path]
   546	            if not defs:
   547	                continue
   548	            classes = [d["name"] for d in defs if d["type"] == "class"]
   549	            functions = [d["name"] for d in defs if d["type"] == "function"]
   550	            structs = [d["name"] for d in defs if d["type"] in ("struct", "interface")]
   551	
   552	            parts = []
   553	            if classes:
   554	                parts.append(f"classes: {', '.join(classes[:10])}")
   555	            if structs:
   556	                parts.append(f"types: {', '.join(structs[:10])}")
   557	            if functions:
   558	                # Only show non-private functions, cap at 8
   559	                pub = [f for f in functions if not f.startswith("_")][:8]
   560	                if pub:
   561	                    parts.append(f"functions: {', '.join(pub)}")
   562	
   563	            if parts:
   564	                lines.append(f"  {path}")
   565	                for p in parts:
   566	                    lines.append(f"    {p}")
   567	        lines.append("")
   568	
   569	    # ── Cycle/Pattern Detection ──
   570	    cycles = _detect_cycles(import_graph)
   571	    if cycles:
   572	        lines.append("CIRCULAR DEPENDENCIES DETECTED:")
   573	        for cycle in cycles[:10]:
   574	            lines.append(f"  ⟲ {' → '.join(cycle)}")
   575	        lines.append("")
   576	
   577	    # ── Shared Definitions ──
   578	    # Find classes/models used across many files
   579	    shared = _find_shared_definitions(definitions_map, imported_by)
   580	    if shared:
   581	        lines.append("WIDELY-USED DEFINITIONS:")
   582	        for name, info in shared[:15]:
   583	            lines.append(f"  {name} (defined in {info['defined_in']}, used by {info['used_by_count']} files)")
   584	        lines.append("")
   585	
   586	    result = "\n".join(lines)
   587	
   588	    # Truncate if too long
   589	    if len(result) > MAX_MAP_CHARS:
   590	        result = result[:MAX_MAP_CHARS] + "\n\n... [architecture map truncated]"
   591	
   592	    return result
   593	
   594	
   595	def _detect_cycles(import_graph: dict) -> list[list[str]]:
   596	    """Detect import cycles using DFS."""
   597	    cycles = []
   598	    visited = set()
   599	    path_set = set()
   600	    path_list = []
   601	
   602	    def dfs(node):
   603	        if len(cycles) >= 10:
   604	            return
   605	        if node in path_set:
   606	            # Found cycle
   607	            idx = path_list.index(node)
   608	            cycle = path_list[idx:] + [node]
   609	            # Use short names
   610	            short = [p.rsplit("/", 1)[-1] for p in cycle]
   611	            cycles.append(short)
   612	            return
   613	        if node in visited:
   614	            return
   615	
   616	        visited.add(node)
   617	        path_set.add(node)
   618	        path_list.append(node)
   619	
   620	        for dep in import_graph.get(node, []):
   621	            dfs(dep)
   622	
   623	        path_set.discard(node)
   624	        path_list.pop()
   625	
   626	    for node in import_graph:
   627	        if node not in visited:
   628	            dfs(node)
   629	
   630	    return cycles
   631	
   632	
   633	def _find_shared_definitions(
   634	    definitions_map: dict,
   635	    imported_by: dict,
   636	) -> list[tuple[str, dict]]:
   637	    """Find definitions that are used across many files."""
   638	    results = []
   639	
   640	    for path, defs in definitions_map.items():
   641	        importers_count = len(imported_by.get(path, set()))
   642	        if importers_count < 2:
   643	            continue
   644	
   645	        for d in defs:
   646	            if d["type"] in ("class", "struct", "interface"):
   647	                results.append((
   648	                    d["name"],
   649	                    {
   650	                        "defined_in": path,
   651	                        "type": d["type"],
   652	                        "used_by_count": importers_count,
   653	                    }
   654	                ))
   655	
   656	    results.sort(key=lambda x: -x[1]["used_by_count"])
   657	    return results│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [027]: backend/services/code_analyzer.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [028/69]: backend/services/code_extractor.py
│ LANGUAGE: python | LINES: 56 | SIZE: 1727 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	Extract fenced code blocks from Markdown.
     3	Handles the special ``language:filename`` format.
     4	"""
     5	
     6	import re
     7	
     8	_FENCE_RE = re.compile(
     9	    r"```(\S*?)(?::(\S+?))?\s*?\n(.*?)```",
    10	    re.DOTALL,
    11	)
    12	
    13	_LANG_EXT = {
    14	    "python": "py", "javascript": "js", "typescript": "ts",
    15	    "csharp": "cs", "cpp": "cpp", "c": "c", "java": "java",
    16	    "ruby": "rb", "go": "go", "rust": "rs", "swift": "swift",
    17	    "kotlin": "kt", "php": "php", "html": "html", "css": "css",
    18	    "scss": "scss", "json": "json", "yaml": "yaml", "yml": "yaml",
    19	    "xml": "xml", "sql": "sql", "bash": "sh", "shell": "sh",
    20	    "sh": "sh", "dockerfile": "Dockerfile", "lua": "lua",
    21	    "dart": "dart", "tsx": "tsx", "jsx": "jsx", "vue": "vue",
    22	    "svelte": "svelte", "toml": "toml", "ini": "ini",
    23	    "markdown": "md", "md": "md", "txt": "txt", "text": "txt",
    24	    "glsl": "glsl", "hlsl": "hlsl", "gdscript": "gd",
    25	}
    26	
    27	
    28	def extract_code_blocks(markdown: str) -> list[dict]:
    29	    """
    30	    Returns list of dicts: {language, filename, code}
    31	    """
    32	    blocks: list[dict] = []
    33	    counter: dict[str, int] = {}
    34	
    35	    for m in _FENCE_RE.finditer(markdown):
    36	        lang = (m.group(1) or "text").lower()
    37	        explicit_filename = m.group(2)          # may be None
    38	        code = m.group(3).strip()
    39	
    40	        if not code:
    41	            continue
    42	
    43	        if explicit_filename:
    44	            filename = explicit_filename
    45	        else:
    46	            ext = _LANG_EXT.get(lang, lang or "txt")
    47	            counter[ext] = counter.get(ext, 0) + 1
    48	            n = counter[ext]
    49	            filename = f"code_{n}.{ext}" if n > 1 else f"code.{ext}"
    50	
    51	        blocks.append({
    52	            "language": lang,
    53	            "filename": filename,
    54	            "code": code,
    55	        })
    56	
    57	    return blocks│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [028]: backend/services/code_extractor.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [029/69]: backend/services/docx_service.py
│ LANGUAGE: python | LINES: 318 | SIZE: 10262 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	Professional DOCX Generation — clean, well-formatted Word documents.
     3	"""
     4	
     5	import io
     6	import re
     7	from docx import Document
     8	from docx.shared import Pt, Inches, RGBColor, Cm
     9	from docx.enum.text import WD_ALIGN_PARAGRAPH
    10	from docx.enum.table import WD_TABLE_ALIGNMENT
    11	from docx.oxml.ns import qn
    12	from docx.oxml import OxmlElement
    13	
    14	
    15	def generate_docx(markdown: str, title: str = "Document") -> bytes:
    16	    doc = Document()
    17	    _setup_styles(doc)
    18	    _set_margins(doc)
    19	
    20	    sections = doc.sections
    21	    for section in sections:
    22	        footer = section.footer
    23	        footer.is_linked_to_previous = False
    24	        p = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph()
    25	        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
    26	        run = p.add_run("Generated by Son of Anton")
    27	        run.font.size = Pt(8)
    28	        run.font.color.rgb = RGBColor(0x99, 0x99, 0x99)
    29	
    30	    elements = _parse_markdown(markdown)
    31	
    32	    for el in elements:
    33	        etype = el["type"]
    34	
    35	        if etype == "heading":
    36	            level = min(el["level"], 4)
    37	            p = doc.add_heading(el["text"], level=level)
    38	            if level == 1:
    39	                p.alignment = WD_ALIGN_PARAGRAPH.LEFT
    40	                run = p.runs[0] if p.runs else p.add_run(el["text"])
    41	                run.font.color.rgb = RGBColor(0xE5, 0x3E, 0x3E)
    42	                _add_bottom_border(p)
    43	
    44	        elif etype == "paragraph":
    45	            p = doc.add_paragraph()
    46	            _add_rich_text(p, el["text"])
    47	
    48	        elif etype == "bullet":
    49	            p = doc.add_paragraph(style="List Bullet")
    50	            _add_rich_text(p, el["text"])
    51	
    52	        elif etype == "numbered":
    53	            p = doc.add_paragraph(style="List Number")
    54	            _add_rich_text(p, el["text"])
    55	
    56	        elif etype == "code":
    57	            _add_code_block(doc, el["code"], el.get("lang", ""))
    58	
    59	        elif etype == "blockquote":
    60	            p = doc.add_paragraph()
    61	            p.paragraph_format.left_indent = Inches(0.5)
    62	            p.paragraph_format.space_before = Pt(6)
    63	            p.paragraph_format.space_after = Pt(6)
    64	            run = p.add_run(el["text"])
    65	            run.font.italic = True
    66	            run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
    67	            _add_left_border(p, "CC3333")
    68	
    69	        elif etype == "hr":
    70	            p = doc.add_paragraph()
    71	            _add_bottom_border(p, color="CCCCCC")
    72	            p.paragraph_format.space_before = Pt(12)
    73	            p.paragraph_format.space_after = Pt(12)
    74	
    75	        elif etype == "table":
    76	            _add_table(doc, el["rows"])
    77	
    78	    buf = io.BytesIO()
    79	    doc.save(buf)
    80	    buf.seek(0)
    81	    return buf.getvalue()
    82	
    83	
    84	def _setup_styles(doc):
    85	    style = doc.styles["Normal"]
    86	    font = style.font
    87	    font.name = "Calibri"
    88	    font.size = Pt(11)
    89	    font.color.rgb = RGBColor(0x33, 0x33, 0x33)
    90	    pf = style.paragraph_format
    91	    pf.space_after = Pt(6)
    92	    pf.line_spacing = 1.15
    93	
    94	    for level in range(1, 5):
    95	        try:
    96	            hs = doc.styles[f"Heading {level}"]
    97	            hs.font.name = "Calibri"
    98	            hs.font.color.rgb = RGBColor(0x1A, 0x1A, 0x2E)
    99	            hs.font.bold = True
   100	            sizes = {1: 24, 2: 18, 3: 14, 4: 12}
   101	            hs.font.size = Pt(sizes.get(level, 12))
   102	            hs.paragraph_format.space_before = Pt(18 if level <= 2 else 12)
   103	            hs.paragraph_format.space_after = Pt(6)
   104	        except KeyError:
   105	            pass
   106	
   107	
   108	def _set_margins(doc):
   109	    for section in doc.sections:
   110	        section.top_margin = Cm(2.5)
   111	        section.bottom_margin = Cm(2.5)
   112	        section.left_margin = Cm(2.5)
   113	        section.right_margin = Cm(2.5)
   114	
   115	
   116	def _add_rich_text(para, text):
   117	    parts = re.split(r'(\*\*[^*]+\*\*|`[^`]+`|\*[^*]+\*|\[[^\]]+\]\([^)]+\))', text)
   118	    for part in parts:
   119	        if not part:
   120	            continue
   121	        if part.startswith("**") and part.endswith("**"):
   122	            run = para.add_run(part[2:-2])
   123	            run.font.bold = True
   124	        elif part.startswith("*") and part.endswith("*"):
   125	            run = para.add_run(part[1:-1])
   126	            run.font.italic = True
   127	        elif part.startswith("`") and part.endswith("`"):
   128	            run = para.add_run(part[1:-1])
   129	            run.font.name = "Consolas"
   130	            run.font.size = Pt(9.5)
   131	            run.font.color.rgb = RGBColor(0xE5, 0x3E, 0x3E)
   132	            _shade_run(run, "F0F0F0")
   133	        elif part.startswith("[") and "](" in part:
   134	            match = re.match(r'\[([^\]]+)\]\(([^)]+)\)', part)
   135	            if match:
   136	                run = para.add_run(match.group(1))
   137	                run.font.color.rgb = RGBColor(0x0E, 0x5E, 0xAD)
   138	                run.font.underline = True
   139	            else:
   140	                para.add_run(part)
   141	        else:
   142	            para.add_run(part)
   143	
   144	
   145	def _shade_run(run, color_hex):
   146	    shd = OxmlElement("w:shd")
   147	    shd.set(qn("w:val"), "clear")
   148	    shd.set(qn("w:color"), "auto")
   149	    shd.set(qn("w:fill"), color_hex)
   150	    run._r.get_or_add_rPr().append(shd)
   151	
   152	
   153	def _add_code_block(doc, code, lang=""):
   154	    tbl = doc.add_table(rows=1, cols=1)
   155	    tbl.alignment = WD_TABLE_ALIGNMENT.LEFT
   156	    cell = tbl.cell(0, 0)
   157	    _set_cell_shading(cell, "1A1B26")
   158	    cell.width = Inches(6)
   159	
   160	    if lang:
   161	        p_lang = cell.paragraphs[0]
   162	        run_lang = p_lang.add_run(lang.upper())
   163	        run_lang.font.size = Pt(8)
   164	        run_lang.font.color.rgb = RGBColor(0xE5, 0x3E, 0x3E)
   165	        run_lang.font.name = "Consolas"
   166	        run_lang.font.bold = True
   167	        p_lang.paragraph_format.space_after = Pt(4)
   168	        p = cell.add_paragraph()
   169	    else:
   170	        p = cell.paragraphs[0]
   171	
   172	    lines = code.split("\n")
   173	    for i, line in enumerate(lines):
   174	        if i > 0:
   175	            p.add_run("\n")
   176	        run = p.add_run(line)
   177	        run.font.name = "Consolas"
   178	        run.font.size = Pt(9)
   179	        run.font.color.rgb = RGBColor(0xD4, 0xD4, 0xD4)
   180	
   181	    p.paragraph_format.space_before = Pt(2)
   182	    p.paragraph_format.space_after = Pt(2)
   183	
   184	    for row in tbl.rows:
   185	        for c in row.cells:
   186	            for paragraph in c.paragraphs:
   187	                paragraph.paragraph_format.line_spacing = 1.2
   188	
   189	
   190	def _set_cell_shading(cell, color_hex):
   191	    shading = OxmlElement("w:shd")
   192	    shading.set(qn("w:val"), "clear")
   193	    shading.set(qn("w:color"), "auto")
   194	    shading.set(qn("w:fill"), color_hex)
   195	    cell._tc.get_or_add_tcPr().append(shading)
   196	
   197	
   198	def _add_table(doc, rows):
   199	    if not rows:
   200	        return
   201	    n_cols = max(len(r) for r in rows)
   202	    tbl = doc.add_table(rows=len(rows), cols=n_cols)
   203	    tbl.style = "Table Grid"
   204	    tbl.alignment = WD_TABLE_ALIGNMENT.LEFT
   205	
   206	    for i, row_data in enumerate(rows):
   207	        for j, cell_text in enumerate(row_data):
   208	            if j < n_cols:
   209	                cell = tbl.cell(i, j)
   210	                cell.text = cell_text.strip()
   211	                for p in cell.paragraphs:
   212	                    p.paragraph_format.space_after = Pt(2)
   213	                    for run in p.runs:
   214	                        run.font.size = Pt(9)
   215	                if i == 0:
   216	                    _set_cell_shading(cell, "E5E5E5")
   217	                    for p in cell.paragraphs:
   218	                        for run in p.runs:
   219	                            run.font.bold = True
   220	
   221	
   222	def _add_bottom_border(para, color="E53E3E"):
   223	    pPr = para._p.get_or_add_pPr()
   224	    pBdr = OxmlElement("w:pBdr")
   225	    bottom = OxmlElement("w:bottom")
   226	    bottom.set(qn("w:val"), "single")
   227	    bottom.set(qn("w:sz"), "6")
   228	    bottom.set(qn("w:space"), "4")
   229	    bottom.set(qn("w:color"), color)
   230	    pBdr.append(bottom)
   231	    pPr.append(pBdr)
   232	
   233	
   234	def _add_left_border(para, color="E53E3E"):
   235	    pPr = para._p.get_or_add_pPr()
   236	    pBdr = OxmlElement("w:pBdr")
   237	    left = OxmlElement("w:left")
   238	    left.set(qn("w:val"), "single")
   239	    left.set(qn("w:sz"), "18")
   240	    left.set(qn("w:space"), "8")
   241	    left.set(qn("w:color"), color)
   242	    pBdr.append(left)
   243	    pPr.append(pBdr)
   244	
   245	
   246	def _parse_markdown(markdown: str) -> list[dict]:
   247	    elements = []
   248	    in_code = False
   249	    code_lang = ""
   250	    code_lines = []
   251	    in_table = False
   252	    table_rows = []
   253	
   254	    for line in markdown.split("\n"):
   255	        if line.strip().startswith("```"):
   256	            if in_code:
   257	                elements.append({"type": "code", "code": "\n".join(code_lines), "lang": code_lang})
   258	                in_code = False
   259	                code_lines = []
   260	                code_lang = ""
   261	            else:
   262	                in_code = True
   263	                raw = line.strip()[3:]
   264	                code_lang = raw.split(":")[0].split()[0] if raw else ""
   265	            continue
   266	
   267	        if in_code:
   268	            code_lines.append(line)
   269	            continue
   270	
   271	        stripped = line.strip()
   272	
   273	        if stripped.startswith("|") and stripped.endswith("|"):
   274	            cols = [c.strip() for c in stripped.strip("|").split("|")]
   275	            if all(re.match(r'^[-:]+$', c) for c in cols):
   276	                continue
   277	            if not in_table:
   278	                in_table = True
   279	                table_rows = []
   280	            table_rows.append(cols)
   281	            continue
   282	        elif in_table:
   283	            elements.append({"type": "table", "rows": table_rows})
   284	            in_table = False
   285	            table_rows = []
   286	
   287	        if not stripped:
   288	            continue
   289	        if stripped == "---" or stripped == "***" or stripped == "___":
   290	            elements.append({"type": "hr"})
   291	            continue
   292	
   293	        heading_match = re.match(r'^(#{1,6})\s+(.+)', stripped)
   294	        if heading_match:
   295	            level = len(heading_match.group(1))
   296	            text = heading_match.group(2).strip()
   297	            elements.append({"type": "heading", "level": level, "text": text})
   298	            continue
   299	
   300	        if stripped.startswith("> "):
   301	            elements.append({"type": "blockquote", "text": stripped[2:]})
   302	            continue
   303	
   304	        if re.match(r'^[-*+]\s', stripped):
   305	            elements.append({"type": "bullet", "text": re.sub(r'^[-*+]\s+', '', stripped)})
   306	            continue
   307	
   308	        if re.match(r'^\d+[.)]\s', stripped):
   309	            elements.append({"type": "numbered", "text": re.sub(r'^\d+[.)]\s+', '', stripped)})
   310	            continue
   311	
   312	        elements.append({"type": "paragraph", "text": stripped})
   313	
   314	    if in_code:
   315	        elements.append({"type": "code", "code": "\n".join(code_lines), "lang": code_lang})
   316	    if in_table and table_rows:
   317	        elements.append({"type": "table", "rows": table_rows})
   318	
   319	    return elements│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [029]: backend/services/docx_service.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [030/69]: backend/services/file_processor.py
│ LANGUAGE: python | LINES: 165 | SIZE: 5511 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	Son of Anton — File Processor
     3	Handles classification, validation, and Claude content-block generation
     4	for uploaded files (images, videos, documents).
     5	"""
     6	
     7	import base64
     8	import mimetypes
     9	from pathlib import Path
    10	
    11	# Claude Bedrock supported formats
    12	IMAGE_FORMATS = {"image/jpeg", "image/png", "image/gif", "image/webp"}
    13	VIDEO_FORMATS = {"video/mp4", "video/webm", "video/mov", "video/mpeg", "video/mkv",
    14	                 "video/x-matroska", "video/quicktime", "video/x-flv",
    15	                 "video/x-ms-wmv", "video/3gpp"}
    16	DOCUMENT_FORMATS = {
    17	    "application/pdf": "pdf",
    18	    "text/csv": "csv",
    19	    "application/msword": "doc",
    20	    "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
    21	    "application/vnd.ms-excel": "xls",
    22	    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
    23	    "text/html": "html",
    24	    "text/plain": "txt",
    25	    "text/markdown": "md",
    26	}
    27	
    28	# Max sizes (bytes)
    29	MAX_IMAGE_SIZE = 20 * 1024 * 1024      # 20MB
    30	MAX_VIDEO_SIZE = 100 * 1024 * 1024      # 100MB (Claude limit ~25MB for video in message)
    31	MAX_DOCUMENT_SIZE = 50 * 1024 * 1024    # 50MB
    32	
    33	ALLOWED_MIMES = IMAGE_FORMATS | VIDEO_FORMATS | set(DOCUMENT_FORMATS.keys())
    34	
    35	
    36	def classify_media(mime_type: str) -> str:
    37	    """Classify a MIME type into a media category."""
    38	    if mime_type in IMAGE_FORMATS:
    39	        return "image"
    40	    if mime_type in VIDEO_FORMATS:
    41	        return "video"
    42	    if mime_type in DOCUMENT_FORMATS:
    43	        return "document"
    44	    if mime_type and mime_type.startswith("text/"):
    45	        return "text"
    46	    return "unknown"
    47	
    48	
    49	def get_max_size(media_type: str) -> int:
    50	    """Return max allowed file size in bytes for a media type."""
    51	    return {
    52	        "image": MAX_IMAGE_SIZE,
    53	        "video": MAX_VIDEO_SIZE,
    54	        "document": MAX_DOCUMENT_SIZE,
    55	        "text": MAX_DOCUMENT_SIZE,
    56	    }.get(media_type, MAX_DOCUMENT_SIZE)
    57	
    58	
    59	def validate_file(filename: str, content_type: str, size: int) -> tuple[bool, str]:
    60	    """Validate an uploaded file. Returns (ok, error_message)."""
    61	    if not content_type:
    62	        guessed, _ = mimetypes.guess_type(filename)
    63	        content_type = guessed or "application/octet-stream"
    64	
    65	    media_type = classify_media(content_type)
    66	
    67	    if media_type == "unknown":
    68	        return False, f"Unsupported file type: {content_type}. Supported: images, videos, PDF, Office docs, text files."
    69	
    70	    max_size = get_max_size(media_type)
    71	    if size > max_size:
    72	        return False, f"File too large ({size / 1024 / 1024:.1f}MB). Max for {media_type}: {max_size / 1024 / 1024:.0f}MB."
    73	
    74	    return True, ""
    75	
    76	
    77	def mime_to_claude_format(mime_type: str, media_type: str) -> str:
    78	    """Convert MIME type to Claude's format string."""
    79	    if media_type == "image":
    80	        return mime_type.split("/")[1]  # jpeg, png, gif, webp
    81	    if media_type == "video":
    82	        mapping = {
    83	            "video/mp4": "mp4",
    84	            "video/webm": "webm",
    85	            "video/quicktime": "mov",
    86	            "video/mov": "mov",
    87	            "video/mpeg": "mpeg",
    88	            "video/mkv": "mkv",
    89	            "video/x-matroska": "mkv",
    90	            "video/x-flv": "flv",
    91	            "video/x-ms-wmv": "wmv",
    92	            "video/3gpp": "three_gp",
    93	        }
    94	        return mapping.get(mime_type, "mp4")
    95	    if media_type == "document":
    96	        return DOCUMENT_FORMATS.get(mime_type, "txt")
    97	    return "txt"
    98	
    99	
   100	def build_content_block(file_path: str, mime_type: str, media_type: str, original_filename: str) -> dict:
   101	    """
   102	    Build a Claude Converse API content block for a file.
   103	    Returns a dict that goes directly into the message content array.
   104	    """
   105	    path = Path(file_path)
   106	    if not path.exists():
   107	        return {"text": f"[Attachment missing: {original_filename}]"}
   108	
   109	    file_bytes = path.read_bytes()
   110	    fmt = mime_to_claude_format(mime_type, media_type)
   111	
   112	    if media_type == "image":
   113	        return {
   114	            "image": {
   115	                "format": fmt,
   116	                "source": {
   117	                    "bytes": file_bytes
   118	                }
   119	            }
   120	        }
   121	    elif media_type == "video":
   122	        return {
   123	            "video": {
   124	                "format": fmt,
   125	                "source": {
   126	                    "bytes": file_bytes
   127	                }
   128	            }
   129	        }
   130	    elif media_type == "document":
   131	        return {
   132	            "document": {
   133	                "format": fmt,
   134	                "name": Path(original_filename).stem[:200],
   135	                "source": {
   136	                    "bytes": file_bytes
   137	                }
   138	            }
   139	        }
   140	    elif media_type == "text":
   141	        try:
   142	            text_content = file_bytes.decode("utf-8", errors="replace")
   143	        except Exception:
   144	            text_content = "[Could not decode text file]"
   145	        return {
   146	            "text": f"--- Content of {original_filename} ---\n{text_content}\n--- End of {original_filename} ---"
   147	        }
   148	    else:
   149	        return {"text": f"[Unsupported attachment: {original_filename}]"}
   150	
   151	
   152	def build_content_blocks_for_attachments(attachments: list, upload_dir: str) -> list[dict]:
   153	    """
   154	    Given a list of attachment DB records, build all Claude content blocks.
   155	    """
   156	    blocks = []
   157	    for att in attachments:
   158	        file_path = str(Path(upload_dir) / att["storage_path"])
   159	        block = build_content_block(
   160	            file_path=file_path,
   161	            mime_type=att["mime_type"],
   162	            media_type=att["media_type"],
   163	            original_filename=att["original_filename"],
   164	        )
   165	        blocks.append(block)
   166	    return blocks│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [030]: backend/services/file_processor.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [031/69]: backend/services/generation_manager.py
│ LANGUAGE: python | LINES: 222 | SIZE: 11946 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	Background generation manager — v4.1.0 with web search.
     3	"""
     4	
     5	import asyncio
     6	import time
     7	from datetime import datetime
     8	from typing import Optional
     9	from dataclasses import dataclass, field
    10	
    11	from backend.database import SessionLocal
    12	from backend.models import User, Chat, Message, ChatAttachment, GitLabSettings, LinkedRepo
    13	from backend.system_prompt import build_full_prompt
    14	from backend.services import bedrock_service, memory_service, rag_service, attachment_service, gitlab_service
    15	
    16	_tree_cache: dict[str, tuple[float, list[dict]]] = {}
    17	TREE_CACHE_TTL = 600
    18	_chat_file_history: dict[str, set[str]] = {}
    19	
    20	
    21	def _get_tree_cache(repo_id, branch):
    22	    key = f"{repo_id}:{branch}"
    23	    if key in _tree_cache:
    24	        ts, tree = _tree_cache[key]
    25	        if time.time() - ts < TREE_CACHE_TTL:
    26	            return tree
    27	    return None
    28	
    29	
    30	def _set_tree_cache(repo_id, branch, tree):
    31	    _tree_cache[f"{repo_id}:{branch}"] = (time.time(), tree)
    32	
    33	
    34	@dataclass
    35	class GenerationState:
    36	    events: list = field(default_factory=list)
    37	    done: asyncio.Event = field(default_factory=asyncio.Event)
    38	    message_id: str = ""
    39	    error: str = ""
    40	
    41	
    42	class GenerationManager:
    43	    def __init__(self):
    44	        self._active: dict[str, GenerationState] = {}
    45	
    46	    def is_active(self, chat_id: str) -> bool:
    47	        state = self._active.get(chat_id)
    48	        return state is not None and not state.done.is_set()
    49	
    50	    def start(self, chat_id, user_id, content, model, max_tokens, reasoning_budget, knowledge_base_id, attachment_ids, web_search=False):
    51	        old = self._active.get(chat_id)
    52	        if old and not old.done.is_set():
    53	            old.done.set()
    54	        state = GenerationState()
    55	        self._active[chat_id] = state
    56	        asyncio.create_task(self._run(state, chat_id, user_id, content, model, max_tokens, reasoning_budget, knowledge_base_id, attachment_ids, web_search))
    57	        return state
    58	
    59	    async def stream_events(self, chat_id):
    60	        state = self._active.get(chat_id)
    61	        if not state: return
    62	        idx = 0
    63	        while True:
    64	            while idx < len(state.events):
    65	                yield state.events[idx]; idx += 1
    66	            if state.done.is_set():
    67	                while idx < len(state.events):
    68	                    yield state.events[idx]; idx += 1
    69	                break
    70	            await asyncio.sleep(0.02)
    71	
    72	    def invalidate_repo_cache(self, repo_id):
    73	        for k in [k for k in _tree_cache if k.startswith(f"{repo_id}:")]:
    74	            _tree_cache.pop(k, None)
    75	
    76	    async def _build_repo_context(self, db, chat, user_query):
    77	        if not chat.linked_repo_id: return None
    78	        repo = db.query(LinkedRepo).filter(LinkedRepo.id == chat.linked_repo_id).first()
    79	        if not repo: return None
    80	        settings = db.query(GitLabSettings).first()
    81	        if not settings or not settings.is_active: return None
    82	        try:
    83	            tree = _get_tree_cache(repo.id, repo.default_branch)
    84	            if tree is None:
    85	                tree = await gitlab_service.get_tree(settings.gitlab_url, settings.gitlab_token, repo.gitlab_project_id, ref=repo.default_branch, recursive=True)
    86	                _set_tree_cache(repo.id, repo.default_branch, tree)
    87	            prev = _chat_file_history.get(chat.id, set())
    88	            result = await gitlab_service.load_smart_files(settings.gitlab_url, settings.gitlab_token, repo.gitlab_project_id, ref=repo.default_branch, tree=tree, user_query=user_query, previous_files=prev)
    89	            loaded = set()
    90	            for f in result["priority_files"]: loaded.add(f["path"])
    91	            for f in result["query_files"]: loaded.add(f["path"])
    92	            if chat.id not in _chat_file_history: _chat_file_history[chat.id] = set()
    93	            _chat_file_history[chat.id].update(loaded)
    94	            return self._format_smart_context(result, tree, repo, db)
    95	        except Exception as e:
    96	            return f"[Repository: {repo.name} — error: {str(e)[:200]}]"
    97	
    98	    def _format_smart_context(self, result, tree, repo, db):
    99	        files_in_tree = sorted([i["path"] for i in tree if i["type"] == "blob"])
   100	        lines = [f"Repository: {repo.name}", f"Branch: {repo.default_branch}", f"Files loaded: {result['files_loaded']}", f"Characters: {result['total_characters']:,}"]
   101	        if repo.architecture_map and repo.map_status == "ready":
   102	            lines.append(""); lines.append(repo.architecture_map); lines.append("")
   103	        lines.append("═" * 50); lines.append("FILE TREE:"); lines.append("═" * 50)
   104	        for fp in files_in_tree: lines.append(f"  {fp}")
   105	        lines.append(""); lines.append("═" * 50); lines.append("LOADED FILES:"); lines.append("═" * 50)
   106	        for f in result["priority_files"]:
   107	            lines.append(f"\n━━━ {f['path']} ━━━"); lines.append(f["content"]); lines.append(f"━━━ end ━━━")
   108	        for f in result["query_files"]:
   109	            lines.append(f"\n━━━ {f['path']} ━━━"); lines.append(f["content"]); lines.append(f"━━━ end ━━━")
   110	        return "\n".join(lines)
   111	
   112	    async def _run(self, state, chat_id, user_id, content, model_id, max_tokens, reasoning_budget, knowledge_base_id, attachment_ids, web_search=False):
   113	        db = SessionLocal()
   114	        try:
   115	            chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user_id).first()
   116	            if not chat:
   117	                state.events.append({"type": "error", "message": "Chat not found"}); return
   118	            db_user = db.query(User).filter(User.id == user_id).first()
   119	            now = datetime.utcnow()
   120	            if db_user.quota_reset_date and now >= db_user.quota_reset_date:
   121	                db_user.tokens_used_this_month = 0
   122	                db_user.quota_reset_date = datetime(now.year + 1, 1, 1) if now.month == 12 else datetime(now.year, now.month + 1, 1)
   123	                db.commit()
   124	            if db_user.tokens_used_this_month >= db_user.quota_tokens_monthly:
   125	                state.events.append({"type": "error", "message": "Monthly quota exceeded."}); return
   126	
   127	            attachments = []
   128	            if attachment_ids:
   129	                attachments = db.query(ChatAttachment).filter(ChatAttachment.id.in_(attachment_ids), ChatAttachment.chat_id == chat_id).all()
   130	            stored_content = content
   131	            if attachments:
   132	                labels = {"image": "Image", "video": "Video", "document": "Document", "text": "File"}
   133	                notes = [f"[{labels.get(a.file_type, 'File')}: {a.original_filename}]" for a in attachments]
   134	                stored_content = "\n".join(notes) + "\n" + content
   135	            user_msg = Message(chat_id=chat_id, role="user", content=stored_content)
   136	            db.add(user_msg); db.commit(); db.refresh(user_msg)
   137	            for att in attachments: att.message_id = user_msg.id
   138	            if attachments: db.commit()
   139	
   140	            kb_id = knowledge_base_id or chat.knowledge_base_id
   141	            rag_context = None
   142	            if kb_id:
   143	                try: rag_context = rag_service.query(kb_id, content, n_results=8)
   144	                except Exception: pass
   145	
   146	            repo_context = await self._build_repo_context(db, chat, content)
   147	            attachment_context = memory_service.gather_attachment_context(chat_id, db)
   148	
   149	            # Web search
   150	            web_context = None
   151	            if web_search:
   152	                try:
   153	                    from backend.services.web_search_service import search_web
   154	                    state.events.append({"type": "status", "message": "Searching the web..."})
   155	                    web_context = await search_web(content, num_results=8, fetch_pages=3)
   156	                except Exception as e:
   157	                    web_context = f"[Web search failed: {str(e)[:100]}]"
   158	
   159	            system_prompt = build_full_prompt(rag_context=rag_context, repo_context=repo_context, attachment_context=attachment_context, web_search_context=web_context)
   160	            messages = memory_service.build_messages(chat, db)
   161	            if attachments and messages and messages[-1]["role"] == "user":
   162	                content_blocks = attachment_service.build_claude_content_blocks(attachments)
   163	                content_blocks.append({"type": "text", "text": content})
   164	                messages[-1]["content"] = content_blocks
   165	
   166	            effective_max = max_tokens
   167	            thinking_config = None
   168	            if reasoning_budget > 0:
   169	                thinking_config = {"enabled": True, "budget_tokens": reasoning_budget}
   170	                effective_max = max_tokens + reasoning_budget
   171	
   172	            full_text = ""; full_thinking = ""; input_tokens = 0; output_tokens = 0; current_block_type = "text"
   173	
   174	            async for event in bedrock_service.stream_response(messages=messages, system_prompt=system_prompt, model_id=model_id, max_tokens=min(effective_max, 65536), thinking_config=thinking_config):
   175	                if state.done.is_set(): break
   176	                evt_type = event.get("type", "")
   177	                if evt_type == "message_start":
   178	                    usage = event.get("message", {}).get("usage", {}); input_tokens = usage.get("input_tokens", 0)
   179	                elif evt_type == "content_block_start":
   180	                    current_block_type = event.get("content_block", {}).get("type", "text")
   181	                    if current_block_type == "thinking": state.events.append({"type": "thinking_start"})
   182	                elif evt_type == "content_block_delta":
   183	                    delta = event.get("delta", {}); dt = delta.get("type", "")
   184	                    if dt == "thinking_delta":
   185	                        t = delta.get("thinking", ""); full_thinking += t; state.events.append({"type": "thinking_delta", "content": t})
   186	                    elif dt == "text_delta":
   187	                        t = delta.get("text", ""); full_text += t; state.events.append({"type": "text_delta", "content": t})
   188	                elif evt_type == "content_block_stop":
   189	                    if current_block_type == "thinking": state.events.append({"type": "thinking_end"})
   190	                elif evt_type == "message_delta":
   191	                    output_tokens = event.get("usage", {}).get("output_tokens", 0)
   192	
   193	            assistant_msg = Message(chat_id=chat_id, role="assistant", content=full_text, thinking_content=full_thinking or None, input_tokens=input_tokens, output_tokens=output_tokens)
   194	            db.add(assistant_msg)
   195	            db_user.tokens_used_this_month += input_tokens + output_tokens
   196	            chat.model = model_id; chat.max_tokens = max_tokens; chat.reasoning_budget = reasoning_budget
   197	            chat.knowledge_base_id = knowledge_base_id or None; chat.updated_at = datetime.utcnow()
   198	            db.commit()
   199	            state.message_id = assistant_msg.id
   200	
   201	            msg_count = db.query(Message).filter(Message.chat_id == chat_id).count()
   202	            if msg_count <= 2 and chat.title == "New Chat":
   203	                try:
   204	                    title = await self._generate_title(content, full_text[:300])
   205	                    chat.title = title[:120]; db.commit()
   206	                    state.events.append({"type": "title_update", "title": chat.title})
   207	                except Exception: pass
   208	
   209	            state.events.append({"type": "usage", "input_tokens": input_tokens, "output_tokens": output_tokens})
   210	            state.events.append({"type": "done", "message_id": assistant_msg.id})
   211	        except Exception as exc:
   212	            state.events.append({"type": "error", "message": str(exc)}); state.error = str(exc)
   213	        finally:
   214	            state.done.set(); db.close()
   215	            await asyncio.sleep(120); self._active.pop(chat_id, None)
   216	
   217	    async def _generate_title(self, user_msg, ai_msg):
   218	        from backend.config import FAST_MODEL
   219	        result = await bedrock_service.invoke_model_simple(model_id=FAST_MODEL, prompt=f"Generate a concise title (max 6 words):\nUser: {user_msg[:200]}\nAssistant: {ai_msg[:200]}\nRespond ONLY with the title.", max_tokens=30)
   220	        return result.strip().strip('"').strip("'")
   221	
   222	
   223	manager = GenerationManager()│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [031]: backend/services/generation_manager.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [032/69]: backend/services/gitlab_service.py
│ LANGUAGE: python | LINES: 636 | SIZE: 23601 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	GitLab CE REST API v4 client — Enhanced for massive codebases.
     3	Smart file selection for repos up to 10M+ tokens.
     4	"""
     5	
     6	import base64
     7	import json
     8	import re
     9	from typing import Optional
    10	from urllib.parse import quote
    11	
    12	import httpx
    13	
    14	
    15	class GitLabError(Exception):
    16	    def __init__(self, status_code: int, detail: str):
    17	        self.status_code = status_code
    18	        self.detail = detail
    19	        super().__init__(f"GitLab {status_code}: {detail}")
    20	
    21	
    22	def _timeout():
    23	    return httpx.Timeout(connect=15.0, read=120.0, write=30.0, pool=30.0)
    24	
    25	
    26	async def _request(method: str, url: str, token: str, **kwargs) -> dict | list:
    27	    async with httpx.AsyncClient(timeout=_timeout()) as client:
    28	        headers = {"Private-Token": token}
    29	        resp = await client.request(method, url, headers=headers, **kwargs)
    30	        if resp.status_code >= 400:
    31	            detail = resp.text[:500]
    32	            raise GitLabError(resp.status_code, detail)
    33	        if resp.status_code == 204:
    34	            return {}
    35	        return resp.json()
    36	
    37	
    38	def _api(gitlab_url: str, path: str) -> str:
    39	    base = gitlab_url.rstrip("/")
    40	    return f"{base}/api/v4{path}"
    41	
    42	
    43	# ═══════════════════════════════════════════════════
    44	#  Connection
    45	# ═══════════════════════════════════════════════════
    46	
    47	async def test_connection(gitlab_url: str, token: str) -> dict:
    48	    url = _api(gitlab_url, "/user")
    49	    data = await _request("GET", url, token)
    50	    return {"ok": True, "username": data.get("username", ""), "name": data.get("name", "")}
    51	
    52	
    53	# ═══════════════════════════════════════════════════
    54	#  Projects
    55	# ═══════════════════════════════════════════════════
    56	
    57	async def list_projects(
    58	    gitlab_url: str, token: str,
    59	    search: Optional[str] = None,
    60	    page: int = 1, per_page: int = 50,
    61	    owned: bool = False,
    62	) -> list[dict]:
    63	    params = {"page": page, "per_page": per_page, "order_by": "updated_at"}
    64	    if search:
    65	        params["search"] = search
    66	    if owned:
    67	        params["owned"] = "true"
    68	    url = _api(gitlab_url, "/projects")
    69	    data = await _request("GET", url, token, params=params)
    70	    return [
    71	        {
    72	            "id": p["id"],
    73	            "name": p["name"],
    74	            "path_with_namespace": p["path_with_namespace"],
    75	            "default_branch": p.get("default_branch", "main"),
    76	            "web_url": p.get("web_url", ""),
    77	            "description": p.get("description") or "",
    78	            "last_activity_at": p.get("last_activity_at", ""),
    79	        }
    80	        for p in data
    81	    ]
    82	
    83	
    84	async def create_project(
    85	    gitlab_url: str, token: str,
    86	    name: str, description: str = "",
    87	    visibility: str = "private",
    88	    initialize_with_readme: bool = True,
    89	) -> dict:
    90	    url = _api(gitlab_url, "/projects")
    91	    body = {
    92	        "name": name, "description": description,
    93	        "visibility": visibility, "initialize_with_readme": initialize_with_readme,
    94	    }
    95	    data = await _request("POST", url, token, json=body)
    96	    return {
    97	        "id": data["id"], "name": data["name"],
    98	        "path_with_namespace": data["path_with_namespace"],
    99	        "default_branch": data.get("default_branch", "main"),
   100	        "web_url": data.get("web_url", ""), "description": data.get("description") or "",
   101	    }
   102	
   103	
   104	async def get_project(gitlab_url: str, token: str, project_id: int) -> dict:
   105	    url = _api(gitlab_url, f"/projects/{project_id}")
   106	    data = await _request("GET", url, token)
   107	    return {
   108	        "id": data["id"], "name": data["name"],
   109	        "path_with_namespace": data["path_with_namespace"],
   110	        "default_branch": data.get("default_branch", "main"),
   111	        "web_url": data.get("web_url", ""), "description": data.get("description") or "",
   112	        "last_activity_at": data.get("last_activity_at", ""),
   113	        "forks_count": data.get("forks_count", 0), "star_count": data.get("star_count", 0),
   114	    }
   115	
   116	
   117	# ═══════════════════════════════════════════════════
   118	#  Repository Tree — loads ALL paths
   119	# ═══════════════════════════════════════════════════
   120	
   121	async def get_tree(
   122	    gitlab_url: str, token: str,
   123	    project_id: int, path: str = "",
   124	    ref: str = "main", recursive: bool = True,
   125	) -> list[dict]:
   126	    all_items = []
   127	    page = 1
   128	    while True:
   129	        params = {
   130	            "ref": ref, "per_page": 100, "page": page,
   131	            "recursive": str(recursive).lower(),
   132	        }
   133	        if path:
   134	            params["path"] = path
   135	        url = _api(gitlab_url, f"/projects/{project_id}/repository/tree")
   136	        try:
   137	            data = await _request("GET", url, token, params=params)
   138	        except GitLabError:
   139	            break
   140	        if not data:
   141	            break
   142	        all_items.extend(data)
   143	        if len(data) < 100:
   144	            break
   145	        page += 1
   146	        if page > 500:  # Up to 50K files
   147	            break
   148	    return [
   149	        {"name": i["name"], "path": i["path"], "type": i["type"], "mode": i.get("mode", "")}
   150	        for i in all_items
   151	    ]
   152	
   153	
   154	def format_tree_for_prompt(items: list[dict], repo_name: str, branch: str) -> str:
   155	    if not items:
   156	        return f"[Repository: {repo_name} ({branch}) — empty or inaccessible]"
   157	
   158	    files = []
   159	    dirs = set()
   160	    for item in sorted(items, key=lambda x: x["path"]):
   161	        if item["type"] == "tree":
   162	            dirs.add(item["path"])
   163	        else:
   164	            files.append(item["path"])
   165	
   166	    lines = [
   167	        f"Repository: {repo_name} (branch: {branch})",
   168	        f"Total files: {len(files)}",
   169	        f"Total directories: {len(dirs)}",
   170	        "",
   171	    ]
   172	    for f in files:
   173	        lines.append(f"  {f}")
   174	    return "\n".join(lines)
   175	
   176	
   177	# ═══════════════════════════════════════════════════
   178	#  File Operations
   179	# ═══════════════════════════════════════════════════
   180	
   181	async def get_file_content(
   182	    gitlab_url: str, token: str,
   183	    project_id: int, file_path: str,
   184	    ref: str = "main",
   185	) -> dict:
   186	    encoded_path = quote(file_path, safe="")
   187	    url = _api(gitlab_url, f"/projects/{project_id}/repository/files/{encoded_path}")
   188	    params = {"ref": ref}
   189	    data = await _request("GET", url, token, params=params)
   190	    content_raw = data.get("content", "")
   191	    encoding = data.get("encoding", "base64")
   192	    if encoding == "base64" and content_raw:
   193	        try:
   194	            content = base64.b64decode(content_raw).decode("utf-8", errors="replace")
   195	        except Exception:
   196	            content = "[Binary file — cannot decode]"
   197	    else:
   198	        content = content_raw
   199	    return {
   200	        "file_path": data.get("file_path", file_path),
   201	        "file_name": data.get("file_name", ""),
   202	        "size": data.get("size", 0),
   203	        "content": content,
   204	        "ref": data.get("ref", ref),
   205	        "last_commit_id": data.get("last_commit_id", ""),
   206	    }
   207	
   208	
   209	async def get_file_raw(
   210	    gitlab_url: str, token: str,
   211	    project_id: int, file_path: str,
   212	    ref: str = "main",
   213	) -> str:
   214	    encoded_path = quote(file_path, safe="")
   215	    url = _api(gitlab_url, f"/projects/{project_id}/repository/files/{encoded_path}/raw")
   216	    params = {"ref": ref}
   217	    async with httpx.AsyncClient(timeout=_timeout()) as client:
   218	        resp = await client.get(url, headers={"Private-Token": token}, params=params)
   219	        if resp.status_code >= 400:
   220	            raise GitLabError(resp.status_code, resp.text[:300])
   221	        return resp.text
   222	
   223	
   224	# ═══════════════════════════════════════════════════
   225	#  Commits
   226	# ═══════════════════════════════════════════════════
   227	
   228	async def commit_files(
   229	    gitlab_url: str, token: str,
   230	    project_id: int, branch: str,
   231	    commit_message: str, actions: list[dict],
   232	) -> dict:
   233	    url = _api(gitlab_url, f"/projects/{project_id}/repository/commits")
   234	    body = {"branch": branch, "commit_message": commit_message, "actions": actions}
   235	    data = await _request("POST", url, token, json=body)
   236	    return {
   237	        "id": data.get("id", ""), "short_id": data.get("short_id", ""),
   238	        "message": data.get("message", ""), "web_url": data.get("web_url", ""),
   239	    }
   240	
   241	
   242	async def commit_single_file(
   243	    gitlab_url: str, token: str,
   244	    project_id: int, branch: str,
   245	    file_path: str, content: str,
   246	    commit_message: str, action: str = "update",
   247	) -> dict:
   248	    actions = [{"action": action, "file_path": file_path, "content": content}]
   249	    return await commit_files(gitlab_url, token, project_id, branch, commit_message, actions)
   250	
   251	
   252	# ═══════════════════════════════════════════════════
   253	#  Branches
   254	# ═══════════════════════════════════════════════════
   255	
   256	async def list_branches(gitlab_url: str, token: str, project_id: int) -> list[dict]:
   257	    url = _api(gitlab_url, f"/projects/{project_id}/repository/branches")
   258	    params = {"per_page": 100}
   259	    data = await _request("GET", url, token, params=params)
   260	    return [
   261	        {
   262	            "name": b["name"], "default": b.get("default", False),
   263	            "web_url": b.get("web_url", ""),
   264	            "commit_short_id": b.get("commit", {}).get("short_id", ""),
   265	            "commit_message": (b.get("commit", {}).get("message") or "")[:100],
   266	        }
   267	        for b in data
   268	    ]
   269	
   270	
   271	async def create_branch(
   272	    gitlab_url: str, token: str,
   273	    project_id: int, branch_name: str, ref: str = "main",
   274	) -> dict:
   275	    url = _api(gitlab_url, f"/projects/{project_id}/repository/branches")
   276	    body = {"branch": branch_name, "ref": ref}
   277	    data = await _request("POST", url, token, json=body)
   278	    return {"name": data["name"], "web_url": data.get("web_url", "")}
   279	
   280	
   281	# ═══════════════════════════════════════════════════
   282	#  Merge Requests
   283	# ═══════════════════════════════════════════════════
   284	
   285	async def create_merge_request(
   286	    gitlab_url: str, token: str,
   287	    project_id: int, source_branch: str, target_branch: str,
   288	    title: str, description: str = "",
   289	) -> dict:
   290	    url = _api(gitlab_url, f"/projects/{project_id}/merge_requests")
   291	    body = {
   292	        "source_branch": source_branch, "target_branch": target_branch,
   293	        "title": title, "description": description,
   294	    }
   295	    data = await _request("POST", url, token, json=body)
   296	    return {
   297	        "iid": data.get("iid"), "title": data.get("title", ""),
   298	        "web_url": data.get("web_url", ""), "state": data.get("state", ""),
   299	    }
   300	
   301	
   302	# ═══════════════════════════════════════════════════
   303	#  SMART FILE SELECTION — the core of large repo support
   304	# ═══════════════════════════════════════════════════
   305	
   306	TEXT_EXTENSIONS = {
   307	    ".py", ".js", ".ts", ".jsx", ".tsx", ".cs", ".java", ".cpp", ".c",
   308	    ".h", ".hpp", ".go", ".rs", ".rb", ".php", ".swift", ".kt", ".lua",
   309	    ".gd", ".html", ".css", ".scss", ".json", ".yaml", ".yml", ".xml",
   310	    ".toml", ".ini", ".cfg", ".sh", ".bash", ".sql", ".md", ".txt",
   311	    ".env", ".dockerfile", ".vue", ".svelte", ".dart", ".r", ".csv",
   312	    ".graphql", ".proto", ".tf", ".hcl", ".gradle", ".cmake",
   313	    ".makefile", ".rake", ".gemspec", ".lock", ".mod", ".sum",
   314	    ".csproj", ".sln", ".props", ".targets", ".fsproj",
   315	}
   316	
   317	# Files that are ALWAYS loaded if they exist (project understanding)
   318	PRIORITY_PATTERNS = [
   319	    # Package manifests & configs
   320	    r"^package\.json$", r"^package-lock\.json$", r"^yarn\.lock$",
   321	    r"^requirements\.txt$", r"^setup\.py$", r"^setup\.cfg$", r"^pyproject\.toml$",
   322	    r"^Pipfile$", r"^Cargo\.toml$", r"^go\.mod$", r"^go\.sum$",
   323	    r"^Gemfile$", r"^pom\.xml$", r"^build\.gradle$",
   324	    r"^\.csproj$", r".*\.sln$",
   325	    # Docker & CI
   326	    r"^Dockerfile", r"^docker-compose", r"^\.dockerignore$",
   327	    r"^\.gitlab-ci\.yml$", r"^\.github/", r"^Jenkinsfile$",
   328	    # Config files
   329	    r"^tsconfig.*\.json$", r"^vite\.config", r"^webpack\.config",
   330	    r"^tailwind\.config", r"^postcss\.config", r"^babel\.config",
   331	    r"^\.eslintrc", r"^\.prettierrc", r"^\.editorconfig$",
   332	    r"^next\.config", r"^nuxt\.config", r"^angular\.json$",
   333	    r"^\.env\.example$", r"^\.env\.sample$",
   334	    # Docs
   335	    r"^README", r"^CHANGELOG", r"^CONTRIBUTING",
   336	    r"^docs/.*\.md$",
   337	    # Entry points
   338	    r"^main\.", r"^index\.", r"^app\.", r"^server\.",
   339	    r"^Program\.", r"^Startup\.",
   340	    r"^src/main\.", r"^src/index\.", r"^src/app\.", r"^src/App\.",
   341	    r"^backend/main\.", r"^frontend/src/main\.", r"^frontend/src/App\.",
   342	    r"^cmd/", r"^internal/",
   343	    # Database
   344	    r".*migrations/.*", r".*models\.", r".*schema\.",
   345	]
   346	
   347	# Maximum characters for file contents in the prompt
   348	MAX_SMART_CHARS = 600_000       # ~150K tokens
   349	MAX_PRIORITY_CHARS = 150_000    # Reserve for priority files
   350	MAX_QUERY_CHARS = 450_000       # For query-relevant files
   351	MAX_FILES_TO_LOAD = 300         # Hard cap on number of files
   352	MAX_SINGLE_FILE = 50_000        # Skip files larger than this
   353	
   354	
   355	def _is_text_file(path: str) -> bool:
   356	    """Check if a file path looks like a text file we can load."""
   357	    lower = path.lower()
   358	    name = lower.rsplit("/", 1)[-1] if "/" in lower else lower
   359	
   360	    # Check known text filenames
   361	    if name in {
   362	        "dockerfile", "makefile", "gemfile", "rakefile", "procfile",
   363	        "vagrantfile", "jenkinsfile", "brewfile", ".gitignore",
   364	        ".dockerignore", ".env.example", ".env.sample",
   365	    }:
   366	        return True
   367	
   368	    # Check extension
   369	    if "." in name:
   370	        ext = "." + name.rsplit(".", 1)[-1]
   371	        return ext in TEXT_EXTENSIONS
   372	
   373	    return False
   374	
   375	
   376	def _is_priority_file(path: str) -> bool:
   377	    """Check if file matches a priority pattern (should always be loaded)."""
   378	    for pattern in PRIORITY_PATTERNS:
   379	        if re.search(pattern, path, re.IGNORECASE):
   380	            return True
   381	    return False
   382	
   383	
   384	def _score_file_for_query(path: str, query: str, previous_files: set[str]) -> float:
   385	    """Score how relevant a file is to the user's message."""
   386	    if not query:
   387	        return 0.0
   388	
   389	    score = 0.0
   390	    lower_path = path.lower()
   391	    lower_query = query.lower()
   392	    path_parts = set(lower_path.replace("/", " ").replace(".", " ").replace("_", " ").replace("-", " ").split())
   393	    name = lower_path.rsplit("/", 1)[-1] if "/" in lower_path else lower_path
   394	    name_no_ext = name.rsplit(".", 1)[0] if "." in name else name
   395	    dir_parts = lower_path.rsplit("/", 1)[0].split("/") if "/" in lower_path else []
   396	
   397	    # Exact path mentioned in query
   398	    if lower_path in lower_query or path in query:
   399	        score += 200
   400	
   401	    # Filename mentioned
   402	    if name in lower_query or name_no_ext in lower_query:
   403	        score += 100
   404	
   405	    # Directory mentioned
   406	    for d in dir_parts:
   407	        if len(d) > 2 and d in lower_query:
   408	            score += 40
   409	
   410	    # Individual path component words in query
   411	    query_words = set(re.findall(r'[a-z]{3,}', lower_query))
   412	    for part in path_parts:
   413	        if len(part) > 2 and part in query_words:
   414	            score += 15
   415	
   416	    # Technology/framework keywords → matching extensions
   417	    tech_ext_map = {
   418	        "react": {".jsx", ".tsx", ".js"},
   419	        "vue": {".vue"},
   420	        "svelte": {".svelte"},
   421	        "python": {".py"},
   422	        "django": {".py"},
   423	        "flask": {".py"},
   424	        "fastapi": {".py"},
   425	        "typescript": {".ts", ".tsx"},
   426	        "javascript": {".js", ".jsx"},
   427	        "rust": {".rs"},
   428	        "go": {".go"},
   429	        "java": {".java"},
   430	        "csharp": {".cs"},
   431	        "c#": {".cs"},
   432	        "unity": {".cs"},
   433	        "ruby": {".rb"},
   434	        "php": {".php"},
   435	        "swift": {".swift"},
   436	        "kotlin": {".kt"},
   437	        "dart": {".dart"},
   438	        "sql": {".sql"},
   439	        "docker": {"dockerfile", ".yml", ".yaml"},
   440	        "css": {".css", ".scss"},
   441	        "html": {".html"},
   442	        "api": {".py", ".js", ".ts", ".go", ".rs"},
   443	        "route": {".py", ".js", ".ts", ".go"},
   444	        "model": {".py", ".cs", ".java", ".ts"},
   445	        "test": {".py", ".js", ".ts", ".java", ".cs"},
   446	        "config": {".json", ".yaml", ".yml", ".toml", ".ini"},
   447	        "database": {".sql", ".py"},
   448	        "migration": {".sql", ".py"},
   449	        "component": {".jsx", ".tsx", ".vue", ".svelte"},
   450	        "style": {".css", ".scss"},
   451	    }
   452	
   453	    ext = ""
   454	    if "." in name:
   455	        ext = "." + name.rsplit(".", 1)[-1]
   456	
   457	    for keyword, extensions in tech_ext_map.items():
   458	        if keyword in lower_query:
   459	            if ext in extensions or name in extensions:
   460	                score += 20
   461	
   462	    # Bonus for files discussed previously in this chat
   463	    if path in previous_files:
   464	        score += 30
   465	
   466	    # Bonus for files in common important directories
   467	    important_dirs = {"src", "lib", "app", "api", "routes", "models", "services", "components", "pages", "views", "controllers", "utils", "helpers", "config", "tests", "test"}
   468	    for d in dir_parts:
   469	        if d in important_dirs:
   470	            score += 5
   471	
   472	    return score
   473	
   474	
   475	async def load_smart_files(
   476	    gitlab_url: str,
   477	    token: str,
   478	    project_id: int,
   479	    ref: str,
   480	    tree: list[dict],
   481	    user_query: str = "",
   482	    previous_files: set[str] | None = None,
   483	) -> dict:
   484	    """
   485	    Smart file loader for massive codebases.
   486	    
   487	    Returns:
   488	        {
   489	            "tree_summary": str,        # Full file tree (paths only)
   490	            "priority_files": [...],     # Always-loaded config/entry files
   491	            "query_files": [...],        # Files relevant to user's question
   492	            "total_files_in_tree": int,
   493	            "files_loaded": int,
   494	            "total_characters": int,
   495	            "skipped_large": int,
   496	        }
   497	    """
   498	    if previous_files is None:
   499	        previous_files = set()
   500	
   501	    # Separate text files from binary
   502	    text_files = []
   503	    for item in tree:
   504	        if item["type"] != "blob":
   505	            continue
   506	        if _is_text_file(item["path"]):
   507	            text_files.append(item["path"])
   508	
   509	    # Split into priority and regular files
   510	    priority_paths = []
   511	    regular_paths = []
   512	    for fp in text_files:
   513	        if _is_priority_file(fp):
   514	            priority_paths.append(fp)
   515	        else:
   516	            regular_paths.append(fp)
   517	
   518	    # Score regular files by relevance to user query
   519	    scored = []
   520	    for fp in regular_paths:
   521	        s = _score_file_for_query(fp, user_query, previous_files)
   522	        scored.append((s, fp))
   523	
   524	    # Sort by score descending
   525	    scored.sort(key=lambda x: -x[0])
   526	
   527	    # Load priority files first
   528	    priority_loaded = []
   529	    priority_chars = 0
   530	    skipped_large = 0
   531	
   532	    for fp in priority_paths[:100]:  # Cap at 100 priority files
   533	        if priority_chars >= MAX_PRIORITY_CHARS:
   534	            break
   535	        try:
   536	            raw = await get_file_raw(gitlab_url, token, project_id, fp, ref=ref)
   537	            if len(raw) > MAX_SINGLE_FILE:
   538	                raw = raw[:MAX_SINGLE_FILE] + f"\n\n... [truncated — file is {len(raw)} chars]"
   539	                skipped_large += 1
   540	            priority_loaded.append({"path": fp, "content": raw})
   541	            priority_chars += len(raw)
   542	        except Exception:
   543	            priority_loaded.append({"path": fp, "content": "[Could not read file]"})
   544	
   545	    # Load query-relevant files
   546	    query_loaded = []
   547	    query_chars = 0
   548	    loaded_paths = {f["path"] for f in priority_loaded}
   549	    files_loaded_count = len(priority_loaded)
   550	
   551	    for score, fp in scored:
   552	        if files_loaded_count >= MAX_FILES_TO_LOAD:
   553	            break
   554	        if query_chars >= MAX_QUERY_CHARS:
   555	            break
   556	        if fp in loaded_paths:
   557	            continue
   558	        # Only load scored files (score > 0) OR if we have room and few files loaded
   559	        if score <= 0 and files_loaded_count > 50:
   560	            break
   561	
   562	        try:
   563	            raw = await get_file_raw(gitlab_url, token, project_id, fp, ref=ref)
   564	            if len(raw) > MAX_SINGLE_FILE:
   565	                raw = raw[:MAX_SINGLE_FILE] + f"\n\n... [truncated — file is {len(raw)} chars]"
   566	                skipped_large += 1
   567	            query_loaded.append({"path": fp, "content": raw, "score": score})
   568	            query_chars += len(raw)
   569	            loaded_paths.add(fp)
   570	            files_loaded_count += 1
   571	        except Exception:
   572	            query_loaded.append({"path": fp, "content": "[Could not read file]", "score": score})
   573	            files_loaded_count += 1
   574	
   575	    return {
   576	        "priority_files": priority_loaded,
   577	        "query_files": query_loaded,
   578	        "total_files_in_tree": len(text_files),
   579	        "total_binary_files": len(tree) - len(text_files),
   580	        "files_loaded": files_loaded_count,
   581	        "priority_chars": priority_chars,
   582	        "query_chars": query_chars,
   583	        "total_characters": priority_chars + query_chars,
   584	        "skipped_large": skipped_large,
   585	    }
   586	
   587	
   588	# ═══════════════════════════════════════════════════
   589	#  Legacy bulk loader (used by /analyze endpoint)
   590	# ═══════════════════════════════════════════════════
   591	
   592	MAX_ANALYSIS_FILES = 200
   593	MAX_ANALYSIS_CHARS = 600_000
   594	
   595	
   596	async def load_project_files(
   597	    gitlab_url: str, token: str,
   598	    project_id: int, ref: str = "main",
   599	    path: str = "",
   600	) -> dict:
   601	    tree = await get_tree(gitlab_url, token, project_id, path=path, ref=ref, recursive=True)
   602	    text_files = []
   603	    for item in tree:
   604	        if item["type"] != "blob":
   605	            continue
   606	        if _is_text_file(item["path"]):
   607	            text_files.append(item["path"])
   608	
   609	    # Sort: priority files first, then alphabetical
   610	    def sort_key(fp):
   611	        return (0 if _is_priority_file(fp) else 1, fp)
   612	
   613	    text_files.sort(key=sort_key)
   614	    text_files = text_files[:MAX_ANALYSIS_FILES]
   615	
   616	    contents = []
   617	    total_chars = 0
   618	    for fp in text_files:
   619	        if total_chars >= MAX_ANALYSIS_CHARS:
   620	            break
   621	        try:
   622	            raw = await get_file_raw(gitlab_url, token, project_id, fp, ref=ref)
   623	            if len(raw) > MAX_SINGLE_FILE:
   624	                raw = raw[:MAX_SINGLE_FILE] + "\n... [truncated]"
   625	            if total_chars + len(raw) > MAX_ANALYSIS_CHARS:
   626	                raw = raw[:MAX_ANALYSIS_CHARS - total_chars] + "\n... [truncated]"
   627	            contents.append({"path": fp, "content": raw})
   628	            total_chars += len(raw)
   629	        except Exception:
   630	            contents.append({"path": fp, "content": "[Could not read file]"})
   631	
   632	    return {
   633	        "total_files_in_tree": len(tree),
   634	        "files_loaded": len(contents),
   635	        "total_characters": total_chars,
   636	        "files": contents,
   637	    }│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [032]: backend/services/gitlab_service.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [033/69]: backend/services/memory_service.py
│ LANGUAGE: python | LINES: 99 | SIZE: 3221 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	Build the messages list for the Bedrock/Anthropic API from chat history.
     3	Also gathers attachment context for persistent file awareness.
     4	"""
     5	
     6	from sqlalchemy.orm import Session
     7	from backend.models import Chat, Message, ChatAttachment
     8	
     9	MAX_CONTEXT_CHARS = 400_000
    10	MAX_MESSAGES = 80
    11	MAX_ATTACHMENT_CONTEXT_CHARS = 200_000
    12	
    13	
    14	def build_messages(chat: Chat, db: Session) -> list[dict]:
    15	    rows: list[Message] = (
    16	        db.query(Message)
    17	        .filter(Message.chat_id == chat.id)
    18	        .order_by(Message.created_at.desc())
    19	        .limit(MAX_MESSAGES)
    20	        .all()
    21	    )
    22	    rows.reverse()
    23	
    24	    if not rows:
    25	        return []
    26	
    27	    total_chars = sum(len(m.content or "") for m in rows)
    28	    idx = 0
    29	    while total_chars > MAX_CONTEXT_CHARS and idx < len(rows) - 2:
    30	        total_chars -= len(rows[idx].content or "")
    31	        idx += 1
    32	
    33	    trimmed = rows[idx:]
    34	
    35	    while trimmed and trimmed[0].role != "user":
    36	        trimmed = trimmed[1:]
    37	
    38	    result: list[dict] = []
    39	    for m in trimmed:
    40	        content = m.content or ""
    41	        if not content.strip():
    42	            continue
    43	        role = m.role
    44	        if role not in ("user", "assistant"):
    45	            continue
    46	        if result and result[-1]["role"] == role:
    47	            result[-1]["content"] += "\n" + content
    48	        else:
    49	            result.append({"role": role, "content": content})
    50	
    51	    return result
    52	
    53	
    54	def gather_attachment_context(chat_id: str, db: Session) -> str | None:
    55	    """
    56	    Collect text extracts from ALL attachments in a chat.
    57	    This ensures uploaded files remain "visible" to the AI
    58	    throughout the entire conversation, not just in the message
    59	    where they were uploaded.
    60	    """
    61	    attachments = (
    62	        db.query(ChatAttachment)
    63	        .filter(ChatAttachment.chat_id == chat_id)
    64	        .order_by(ChatAttachment.created_at)
    65	        .all()
    66	    )
    67	
    68	    if not attachments:
    69	        return None
    70	
    71	    parts = []
    72	    total_chars = 0
    73	
    74	    for att in attachments:
    75	        if att.file_type in ("image",):
    76	            # Images: just note they exist (can't include binary in text)
    77	            entry = f"[Uploaded Image: {att.original_filename} ({att.file_size} bytes)]"
    78	        elif att.file_type in ("video",):
    79	            entry = f"[Uploaded Video: {att.original_filename} ({att.file_size} bytes)]"
    80	        elif att.text_extract:
    81	            # Text files and PDFs: include full content
    82	            text = att.text_extract
    83	            remaining = MAX_ATTACHMENT_CONTEXT_CHARS - total_chars
    84	            if remaining <= 0:
    85	                parts.append(f"[{att.original_filename}: content truncated — context limit reached]")
    86	                continue
    87	            if len(text) > remaining:
    88	                text = text[:remaining] + "\n... [truncated]"
    89	            entry = (
    90	                f"━━━ {att.original_filename} ({att.file_type}, {att.file_size} bytes) ━━━\n"
    91	                f"{text}\n"
    92	                f"━━━ end of {att.original_filename} ━━━"
    93	            )
    94	        else:
    95	            entry = f"[Uploaded {att.file_type}: {att.original_filename} ({att.file_size} bytes) — no text extracted]"
    96	
    97	        parts.append(entry)
    98	        total_chars += len(entry)
    99	
   100	    return "\n\n".join(parts) if parts else None│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [033]: backend/services/memory_service.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [034/69]: backend/services/pptx_service.py
│ LANGUAGE: python | LINES: 292 | SIZE: 9522 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	Professional PPTX Generation — dark-themed, branded presentations.
     3	"""
     4	
     5	import io
     6	import re
     7	from pptx import Presentation
     8	from pptx.util import Inches, Pt, Emu
     9	from pptx.dml.color import RGBColor
    10	from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
    11	from pptx.enum.shapes import MSO_SHAPE
    12	
    13	BG = RGBColor(0x0F, 0x0F, 0x1A)
    14	BG_LIGHT = RGBColor(0x16, 0x16, 0x2A)
    15	WHITE = RGBColor(0xFF, 0xFF, 0xFF)
    16	BODY = RGBColor(0xE2, 0xE2, 0xEA)
    17	MUTED = RGBColor(0x6B, 0x6B, 0x8A)
    18	ACCENT = RGBColor(0xE5, 0x3E, 0x3E)
    19	CODE_BG = RGBColor(0x1A, 0x1B, 0x26)
    20	
    21	SLIDE_W = Inches(13.333)
    22	SLIDE_H = Inches(7.5)
    23	FONT = "Calibri"
    24	MONO = "Consolas"
    25	
    26	
    27	def generate_pptx(markdown: str, title: str = "Presentation") -> bytes:
    28	    prs = Presentation()
    29	    prs.slide_width = SLIDE_W
    30	    prs.slide_height = SLIDE_H
    31	
    32	    slides_data = _parse_slides(markdown, title)
    33	
    34	    for sd in slides_data:
    35	        if sd["type"] == "title":
    36	            _add_title_slide(prs, sd)
    37	        elif sd["type"] == "code":
    38	            _add_code_slide(prs, sd)
    39	        else:
    40	            _add_content_slide(prs, sd)
    41	
    42	    _add_end_slide(prs)
    43	
    44	    buf = io.BytesIO()
    45	    prs.save(buf)
    46	    buf.seek(0)
    47	    return buf.getvalue()
    48	
    49	
    50	def _set_bg(slide, color=BG):
    51	    bg = slide.background
    52	    fill = bg.fill
    53	    fill.solid()
    54	    fill.fore_color.rgb = color
    55	
    56	
    57	def _add_accent_bar(slide):
    58	    shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, Inches(0.07), SLIDE_H)
    59	    shape.fill.solid()
    60	    shape.fill.fore_color.rgb = ACCENT
    61	    shape.line.fill.background()
    62	
    63	
    64	def _add_accent_line(slide, left, top, width):
    65	    shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, left, top, width, Pt(3))
    66	    shape.fill.solid()
    67	    shape.fill.fore_color.rgb = ACCENT
    68	    shape.line.fill.background()
    69	
    70	
    71	def _add_textbox(slide, left, top, width, height):
    72	    return slide.shapes.add_textbox(left, top, width, height)
    73	
    74	
    75	def _set_para(para, text, size, color, bold=False, font=FONT, align=PP_ALIGN.LEFT):
    76	    para.text = _clean_md(text)
    77	    para.font.size = Pt(size)
    78	    para.font.color.rgb = color
    79	    para.font.bold = bold
    80	    para.font.name = font
    81	    para.alignment = align
    82	    para.space_after = Pt(4)
    83	    para.space_before = Pt(2)
    84	
    85	
    86	def _clean_md(text):
    87	    text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
    88	    text = re.sub(r'\*(.+?)\*', r'\1', text)
    89	    text = re.sub(r'`(.+?)`', r'\1', text)
    90	    text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text)
    91	    return text.strip()
    92	
    93	
    94	def _add_title_slide(prs, sd):
    95	    slide = prs.slides.add_slide(prs.slide_layouts[6])
    96	    _set_bg(slide)
    97	
    98	    tb = _add_textbox(slide, Inches(1.5), Inches(2), Inches(10), Inches(1.5))
    99	    tf = tb.text_frame
   100	    tf.word_wrap = True
   101	    _set_para(tf.paragraphs[0], sd["title"], 40, WHITE, bold=True, align=PP_ALIGN.CENTER)
   102	
   103	    _add_accent_line(slide, Inches(5), Inches(3.6), Inches(3.333))
   104	
   105	    if sd.get("subtitle"):
   106	        tb2 = _add_textbox(slide, Inches(2), Inches(4), Inches(9), Inches(1))
   107	        tf2 = tb2.text_frame
   108	        tf2.word_wrap = True
   109	        _set_para(tf2.paragraphs[0], sd["subtitle"], 20, MUTED, align=PP_ALIGN.CENTER)
   110	
   111	    tb3 = _add_textbox(slide, Inches(4), Inches(6.5), Inches(5), Inches(0.5))
   112	    tf3 = tb3.text_frame
   113	    _set_para(tf3.paragraphs[0], "Generated by Son of Anton", 10, MUTED, align=PP_ALIGN.CENTER)
   114	
   115	
   116	def _add_content_slide(prs, sd):
   117	    slide = prs.slides.add_slide(prs.slide_layouts[6])
   118	    _set_bg(slide)
   119	    _add_accent_bar(slide)
   120	
   121	    if sd.get("title"):
   122	        tb = _add_textbox(slide, Inches(0.8), Inches(0.4), Inches(11.5), Inches(0.8))
   123	        tf = tb.text_frame
   124	        tf.word_wrap = True
   125	        _set_para(tf.paragraphs[0], sd["title"], 28, WHITE, bold=True)
   126	        _add_accent_line(slide, Inches(0.8), Inches(1.25), Inches(2))
   127	
   128	    top = Inches(1.6) if sd.get("title") else Inches(0.5)
   129	    tb = _add_textbox(slide, Inches(0.8), top, Inches(11.5), SLIDE_H - top - Inches(0.5))
   130	    tf = tb.text_frame
   131	    tf.word_wrap = True
   132	
   133	    first = True
   134	    for item in sd.get("items", []):
   135	        if first:
   136	            p = tf.paragraphs[0]
   137	            first = False
   138	        else:
   139	            p = tf.add_paragraph()
   140	
   141	        itype = item["type"]
   142	        text = item["text"]
   143	
   144	        if itype == "subheading":
   145	            _set_para(p, text, 22, ACCENT, bold=True)
   146	            p.space_before = Pt(16)
   147	        elif itype == "bullet":
   148	            _set_para(p, f"  •  {text}", 16, BODY)
   149	            p.space_before = Pt(6)
   150	        elif itype == "numbered":
   151	            _set_para(p, f"  {item.get('num', '•')}  {text}", 16, BODY)
   152	            p.space_before = Pt(6)
   153	        elif itype == "code":
   154	            _set_para(p, text[:500], 12, RGBColor(0xA0, 0xE0, 0xA0), font=MONO)
   155	            p.space_before = Pt(8)
   156	        else:
   157	            _set_para(p, text, 16, BODY)
   158	            p.space_before = Pt(6)
   159	
   160	
   161	def _add_code_slide(prs, sd):
   162	    slide = prs.slides.add_slide(prs.slide_layouts[6])
   163	    _set_bg(slide)
   164	    _add_accent_bar(slide)
   165	
   166	    if sd.get("title"):
   167	        tb = _add_textbox(slide, Inches(0.8), Inches(0.4), Inches(11.5), Inches(0.7))
   168	        tf = tb.text_frame
   169	        _set_para(tf.paragraphs[0], sd["title"], 24, WHITE, bold=True)
   170	
   171	    code_shape = slide.shapes.add_shape(
   172	        MSO_SHAPE.ROUNDED_RECTANGLE,
   173	        Inches(0.8), Inches(1.3), Inches(11.5), Inches(5.5),
   174	    )
   175	    code_shape.fill.solid()
   176	    code_shape.fill.fore_color.rgb = CODE_BG
   177	    code_shape.line.color.rgb = RGBColor(0x2A, 0x2A, 0x4A)
   178	    code_shape.line.width = Pt(1)
   179	
   180	    tf = code_shape.text_frame
   181	    tf.word_wrap = True
   182	    tf.margin_left = Inches(0.3)
   183	    tf.margin_top = Inches(0.2)
   184	
   185	    code = sd.get("code", "")
   186	    lines = code.split("\n")[:35]
   187	    for i, line in enumerate(lines):
   188	        p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
   189	        p.text = line
   190	        p.font.size = Pt(11)
   191	        p.font.name = MONO
   192	        p.font.color.rgb = RGBColor(0xC0, 0xC0, 0xD0)
   193	        p.space_after = Pt(1)
   194	        p.space_before = Pt(1)
   195	
   196	
   197	def _add_end_slide(prs):
   198	    slide = prs.slides.add_slide(prs.slide_layouts[6])
   199	    _set_bg(slide)
   200	    tb = _add_textbox(slide, Inches(2), Inches(2.5), Inches(9), Inches(1.5))
   201	    tf = tb.text_frame
   202	    tf.word_wrap = True
   203	    _set_para(tf.paragraphs[0], "Thank You", 44, WHITE, bold=True, align=PP_ALIGN.CENTER)
   204	    _add_accent_line(slide, Inches(5.5), Inches(4), Inches(2.333))
   205	    tb2 = _add_textbox(slide, Inches(3), Inches(4.5), Inches(7), Inches(0.5))
   206	    tf2 = tb2.text_frame
   207	    _set_para(tf2.paragraphs[0], "Powered by Son of Anton 🔥", 14, MUTED, align=PP_ALIGN.CENTER)
   208	
   209	
   210	def _parse_slides(markdown: str, default_title: str = "Presentation") -> list[dict]:
   211	    slides = []
   212	    in_code = False
   213	    code_lang = ""
   214	    code_lines = []
   215	    current = None
   216	    num_counter = 0
   217	
   218	    for line in markdown.split("\n"):
   219	        if line.strip().startswith("```"):
   220	            if in_code:
   221	                code_text = "\n".join(code_lines)
   222	                if len(code_text.strip()) > 10:
   223	                    slides.append({
   224	                        "type": "code",
   225	                        "title": f"Code ({code_lang})" if code_lang else "Code",
   226	                        "code": code_text,
   227	                    })
   228	                in_code = False
   229	                code_lines = []
   230	                code_lang = ""
   231	            else:
   232	                in_code = True
   233	                raw = line.strip()[3:]
   234	                code_lang = raw.split(":")[0].split()[0] if raw else ""
   235	            continue
   236	
   237	        if in_code:
   238	            code_lines.append(line)
   239	            continue
   240	
   241	        stripped = line.strip()
   242	        if not stripped:
   243	            continue
   244	        if stripped == "---":
   245	            current = None
   246	            num_counter = 0
   247	            continue
   248	
   249	        if stripped.startswith("# ") and not stripped.startswith("## "):
   250	            title_text = stripped[2:].strip()
   251	            subtitle = ""
   252	            current = {"type": "title", "title": title_text, "subtitle": subtitle, "items": []}
   253	            slides.append(current)
   254	            num_counter = 0
   255	            continue
   256	
   257	        if stripped.startswith("## "):
   258	            current = {"type": "content", "title": stripped[3:].strip(), "items": []}
   259	            slides.append(current)
   260	            num_counter = 0
   261	            continue
   262	
   263	        if current is None:
   264	            current = {"type": "content", "title": "", "items": []}
   265	            slides.append(current)
   266	
   267	        if current["type"] == "title" and not current.get("subtitle") and len(current.get("items", [])) == 0:
   268	            current["subtitle"] = _clean_md(stripped)
   269	            continue
   270	
   271	        if stripped.startswith("### "):
   272	            current["items"].append({"type": "subheading", "text": stripped[4:].strip()})
   273	        elif re.match(r'^[-*+]\s', stripped):
   274	            current["items"].append({"type": "bullet", "text": re.sub(r'^[-*+]\s+', '', stripped)})
   275	        elif re.match(r'^\d+[.)]\s', stripped):
   276	            num_counter += 1
   277	            current["items"].append({"type": "numbered", "text": re.sub(r'^\d+[.)]\s+', '', stripped), "num": str(num_counter)})
   278	        else:
   279	            current["items"].append({"type": "text", "text": stripped})
   280	
   281	        if len(current.get("items", [])) > 10:
   282	            current = None
   283	
   284	    if not slides:
   285	        slides.append({"type": "title", "title": default_title, "subtitle": "", "items": []})
   286	        chunks = [s.strip() for s in markdown.split("\n\n") if s.strip()]
   287	        for chunk in chunks[:10]:
   288	            slides.append({
   289	                "type": "content", "title": "",
   290	                "items": [{"type": "text", "text": chunk[:300]}],
   291	            })
   292	
   293	    return slides│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [034]: backend/services/pptx_service.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [035/69]: backend/services/rag_service.py
│ LANGUAGE: python | LINES: 110 | SIZE: 3196 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	RAG (Retrieval-Augmented Generation) via ChromaDB.
     3	Each knowledge base maps to a ChromaDB collection.
     4	Supports document-level deletion by filename metadata.
     5	"""
     6	
     7	import os
     8	from uuid import uuid4
     9	
    10	import chromadb
    11	
    12	from backend.config import CHROMADB_PATH
    13	
    14	os.makedirs(CHROMADB_PATH, exist_ok=True)
    15	
    16	_chroma_client = chromadb.PersistentClient(path=CHROMADB_PATH)
    17	
    18	
    19	def _col_name(collection_id: str) -> str:
    20	    """Sanitise for ChromaDB collection names (3-63 chars, alphanum/dash/underscore)."""
    21	    name = f"kb-{collection_id}"[:63]
    22	    return name
    23	
    24	
    25	def create_collection(collection_id: str):
    26	    _chroma_client.get_or_create_collection(name=_col_name(collection_id))
    27	
    28	
    29	def delete_collection(collection_id: str):
    30	    try:
    31	        _chroma_client.delete_collection(name=_col_name(collection_id))
    32	    except Exception:
    33	        pass
    34	
    35	
    36	def add_documents(
    37	    collection_id: str,
    38	    documents: list[str],
    39	    metadatas: list[dict] | None = None,
    40	):
    41	    col = _chroma_client.get_or_create_collection(name=_col_name(collection_id))
    42	    ids = [str(uuid4()) for _ in documents]
    43	    batch_size = 500
    44	    for i in range(0, len(documents), batch_size):
    45	        batch_docs = documents[i : i + batch_size]
    46	        batch_ids = ids[i : i + batch_size]
    47	        batch_meta = metadatas[i : i + batch_size] if metadatas else None
    48	        col.add(documents=batch_docs, ids=batch_ids, metadatas=batch_meta)
    49	
    50	
    51	def delete_document_chunks(collection_id: str, filename: str) -> int:
    52	    """
    53	    Delete all vector chunks belonging to a specific document (by filename).
    54	    Returns the number of chunks removed.
    55	    """
    56	    try:
    57	        col = _chroma_client.get_collection(name=_col_name(collection_id))
    58	    except Exception:
    59	        return 0
    60	
    61	    if col.count() == 0:
    62	        return 0
    63	
    64	    # Fetch all chunk IDs that match this filename
    65	    try:
    66	        results = col.get(
    67	            where={"filename": filename},
    68	            include=[],  # We only need IDs
    69	        )
    70	        chunk_ids = results.get("ids", [])
    71	        if not chunk_ids:
    72	            return 0
    73	
    74	        # Delete in batches (ChromaDB can handle large deletes but let's be safe)
    75	        batch_size = 500
    76	        for i in range(0, len(chunk_ids), batch_size):
    77	            batch = chunk_ids[i : i + batch_size]
    78	            col.delete(ids=batch)
    79	
    80	        return len(chunk_ids)
    81	    except Exception:
    82	        return 0
    83	
    84	
    85	def query(collection_id: str, query_text: str, n_results: int = 8) -> str | None:
    86	    """
    87	    Return a formatted string of the top matching chunks,
    88	    or None if the collection is empty / missing.
    89	    """
    90	    try:
    91	        col = _chroma_client.get_collection(name=_col_name(collection_id))
    92	    except Exception:
    93	        return None
    94	
    95	    if col.count() == 0:
    96	        return None
    97	
    98	    results = col.query(query_texts=[query_text], n_results=min(n_results, col.count()))
    99	
   100	    docs = results.get("documents", [[]])[0]
   101	    metas = results.get("metadatas", [[]])[0]
   102	
   103	    if not docs:
   104	        return None
   105	
   106	    parts: list[str] = []
   107	    for i, (doc, meta) in enumerate(zip(docs, metas), 1):
   108	        src = meta.get("filename", "unknown") if meta else "unknown"
   109	        parts.append(f"[Source {i}: {src}]\n{doc}")
   110	
   111	    return "\n\n---\n\n".join(parts)│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [035]: backend/services/rag_service.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [036/69]: backend/services/web_search_service.py
│ LANGUAGE: python | LINES: 351 | SIZE: 13521 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	Web Search Service — v4.2.0 — SerpAPI (Google Search)
     3	Clean. Reliable. No scraping bullshit.
     4	"""
     5	
     6	import asyncio
     7	import logging
     8	from urllib.parse import urlencode
     9	
    10	import httpx
    11	
    12	from backend.config import SERPAPI_KEY
    13	
    14	logger = logging.getLogger("son_of_anton.web_search")
    15	
    16	# ═══════════════════════════════════════════════════
    17	#  SerpAPI Configuration
    18	# ═══════════════════════════════════════════════════
    19	
    20	SERPAPI_BASE = "https://serpapi.com/search"
    21	
    22	_USER_AGENTS = [
    23	    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
    24	    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
    25	]
    26	
    27	# Domains we never want cluttering results
    28	BLOCKED_DOMAINS = {
    29	    "baidu.com", "zhihu.com", "csdn.net", "bilibili.com", "weibo.com",
    30	    "sogou.com", "163.com", "qq.com", "taobao.com", "jd.com",
    31	    "douyin.com", "tiktok.com", "yandex.ru", "mail.ru",
    32	}
    33	
    34	
    35	def _is_valid_result(url: str, title: str) -> bool:
    36	    """Filter garbage results."""
    37	    if not url or not url.startswith("http"):
    38	        return False
    39	    if not title or len(title.strip()) < 3:
    40	        return False
    41	    try:
    42	        from urllib.parse import urlparse
    43	        domain = urlparse(url).netloc.lower().lstrip("www.")
    44	        for blocked in BLOCKED_DOMAINS:
    45	            if domain == blocked or domain.endswith("." + blocked):
    46	                return False
    47	    except Exception:
    48	        pass
    49	    return True
    50	
    51	
    52	# ═══════════════════════════════════════════════════
    53	#  Main Entry Point
    54	# ═══════════════════════════════════════════════════
    55	
    56	async def search_web(query: str, num_results: int = 8, fetch_pages: int = 3) -> str:
    57	    """
    58	    Search the web via SerpAPI (Google).
    59	    Returns formatted search results string for LLM context.
    60	    """
    61	    logger.info(f"Web search initiated: query='{query[:80]}', num_results={num_results}")
    62	
    63	    if not SERPAPI_KEY:
    64	        logger.error("SERPAPI_KEY not configured!")
    65	        return f"[Web search failed: SerpAPI key not configured. Answer from your own knowledge.]"
    66	
    67	    results = await _serpapi_search(query, num_results)
    68	
    69	    if not results:
    70	        logger.warning(f"SerpAPI returned no results for: '{query[:80]}'")
    71	        return f"[Web search for '{query}' returned no results. Answer from your own knowledge.]"
    72	
    73	    logger.info(f"SerpAPI returned {len(results)} results for: '{query[:60]}'")
    74	
    75	    # Format results
    76	    lines = [
    77	        "═" * 60,
    78	        "WEB SEARCH RESULTS (via Google/SerpAPI)",
    79	        f"Query: {query}",
    80	        f"Results: {len(results)}",
    81	        "═" * 60, "",
    82	    ]
    83	    for i, r in enumerate(results, 1):
    84	        lines.append(f"[{i}] {r['title']}")
    85	        if r.get("url"):
    86	            lines.append(f"    URL: {r['url']}")
    87	        if r.get("snippet"):
    88	            lines.append(f"    {r['snippet']}")
    89	        if r.get("date"):
    90	            lines.append(f"    Date: {r['date']}")
    91	        lines.append("")
    92	
    93	    # Fetch full page content for top results
    94	    if fetch_pages > 0:
    95	        # Only fetch actual URLs (skip knowledge graph entries without URLs)
    96	        fetchable = [r for r in results if r.get("url") and r["url"].startswith("http")]
    97	        detailed = await _fetch_pages(fetchable[:fetch_pages])
    98	        if detailed:
    99	            lines.append("═" * 60)
   100	            lines.append("DETAILED PAGE CONTENT:")
   101	            lines.append("═" * 60)
   102	            for d in detailed:
   103	                lines.append(f"\n━━━ {d['title']} ━━━")
   104	                lines.append(f"URL: {d['url']}")
   105	                lines.append(d["content"][:4000])
   106	                lines.append(f"━━━ end ━━━\n")
   107	
   108	    return "\n".join(lines)
   109	
   110	
   111	# ═══════════════════════════════════════════════════
   112	#  SerpAPI Google Search
   113	# ═══════════════════════════════════════════════════
   114	
   115	async def _serpapi_search(query: str, n: int) -> list[dict]:
   116	    """Hit SerpAPI's Google Search endpoint and parse results."""
   117	    try:
   118	        params = {
   119	            "q": query,
   120	            "api_key": SERPAPI_KEY,
   121	            "engine": "google",
   122	            "num": min(n + 5, 20),
   123	            "hl": "en",
   124	            "gl": "us",
   125	            "safe": "off",
   126	            "no_cache": "false",
   127	        }
   128	
   129	        async with httpx.AsyncClient(timeout=30.0) as client:
   130	            resp = await client.get(SERPAPI_BASE, params=params)
   131	
   132	            if resp.status_code != 200:
   133	                error_text = resp.text[:500]
   134	                logger.error(f"SerpAPI HTTP {resp.status_code}: {error_text}")
   135	                try:
   136	                    err_data = resp.json()
   137	                    logger.error(f"SerpAPI error: {err_data.get('error', 'unknown')}")
   138	                except Exception:
   139	                    pass
   140	                return []
   141	
   142	            data = resp.json()
   143	
   144	        if "error" in data:
   145	            logger.error(f"SerpAPI error: {data['error']}")
   146	            return []
   147	
   148	        results = []
   149	
   150	        # ── Answer box / Featured snippet (highest priority) ──
   151	        answer = data.get("answer_box", {})
   152	        if answer:
   153	            answer_text = (
   154	                answer.get("answer", "")
   155	                or answer.get("snippet", "")
   156	                or answer.get("result", "")
   157	            )
   158	            if answer_text:
   159	                results.append({
   160	                    "title": f"[Featured Answer] {answer.get('title', query)}",
   161	                    "url": answer.get("link", ""),
   162	                    "snippet": str(answer_text)[:500],
   163	                    "date": "",
   164	                })
   165	
   166	        # ── Knowledge graph ──
   167	        kg = data.get("knowledge_graph", {})
   168	        if kg and kg.get("title"):
   169	            kg_parts = []
   170	            if kg.get("description"):
   171	                kg_parts.append(kg["description"])
   172	            if kg.get("type"):
   173	                kg_parts.append(f"Type: {kg['type']}")
   174	
   175	            for key in ["born", "died", "founded", "headquarters", "ceo",
   176	                        "revenue", "employees", "website", "nationality",
   177	                        "genre", "awards", "education", "height", "weight",
   178	                        "capital", "population", "area", "currency",
   179	                        "president", "prime_minister"]:
   180	                val = kg.get(key)
   181	                if val:
   182	                    kg_parts.append(f"{key.replace('_', ' ').title()}: {val}")
   183	
   184	            # Also grab "known attributes" list if present
   185	            for attr in kg.get("attributes", {}).items():
   186	                if len(kg_parts) < 15:
   187	                    kg_parts.append(f"{attr[0]}: {attr[1]}")
   188	
   189	            if kg_parts:
   190	                results.append({
   191	                    "title": f"[Knowledge Graph] {kg['title']}",
   192	                    "url": kg.get("website", kg.get("source", {}).get("link", "")),
   193	                    "snippet": " | ".join(kg_parts),
   194	                    "date": "",
   195	                })
   196	
   197	        # ── Organic results (main search results) ──
   198	        for item in data.get("organic_results", []):
   199	            title = item.get("title", "").strip()
   200	            url = item.get("link", "").strip()
   201	            snippet = item.get("snippet", "").strip()
   202	            date = item.get("date", "")
   203	
   204	            # Rich snippet extras
   205	            rich_snippet = item.get("rich_snippet", {})
   206	            if rich_snippet:
   207	                top = rich_snippet.get("top", {})
   208	                if top.get("detected_extensions", {}).get("rating"):
   209	                    snippet += f" | Rating: {top['detected_extensions']['rating']}"
   210	
   211	            if _is_valid_result(url, title):
   212	                results.append({
   213	                    "title": title,
   214	                    "url": url,
   215	                    "snippet": snippet,
   216	                    "date": date,
   217	                })
   218	
   219	            if len(results) >= n + 3:  # +3 for KG/answer/news
   220	                break
   221	
   222	        # ── Top stories (news) ──
   223	        for story in data.get("top_stories", [])[:3]:
   224	            title = story.get("title", "").strip()
   225	            url = story.get("link", "").strip()
   226	            source = story.get("source", "")
   227	            date = story.get("date", "")
   228	
   229	            if url and title:
   230	                results.append({
   231	                    "title": f"[News] {title}",
   232	                    "url": url,
   233	                    "snippet": f"Source: {source}" + (f" | {date}" if date else ""),
   234	                    "date": date,
   235	                })
   236	
   237	        # ── Related questions (People Also Ask) ──
   238	        paa = data.get("related_questions", [])
   239	        if paa:
   240	            paa_lines = []
   241	            for q in paa[:4]:
   242	                question = q.get("question", "")
   243	                paa_snippet = q.get("snippet", "")
   244	                if question:
   245	                    entry = question
   246	                    if paa_snippet:
   247	                        entry += f" → {paa_snippet[:150]}"
   248	                    paa_lines.append(entry)
   249	            if paa_lines:
   250	                results.append({
   251	                    "title": "[People Also Ask]",
   252	                    "url": "",
   253	                    "snippet": " | ".join(paa_lines),
   254	                    "date": "",
   255	                })
   256	
   257	        # ── Related searches ──
   258	        related = data.get("related_searches", [])
   259	        if related:
   260	            related_queries = [r.get("query", "") for r in related[:5] if r.get("query")]
   261	            if related_queries:
   262	                results.append({
   263	                    "title": "[Related Searches]",
   264	                    "url": "",
   265	                    "snippet": " | ".join(related_queries),
   266	                    "date": "",
   267	                })
   268	
   269	        logger.info(f"SerpAPI: {len(data.get('organic_results', []))} organic, "
   270	                     f"KG={'yes' if kg.get('title') else 'no'}, "
   271	                     f"answer={'yes' if answer else 'no'}, "
   272	                     f"news={len(data.get('top_stories', []))}, "
   273	                     f"total={len(results)}")
   274	
   275	        return results
   276	
   277	    except httpx.TimeoutException:
   278	        logger.error("SerpAPI request timed out")
   279	        return []
   280	    except Exception as e:
   281	        logger.error(f"SerpAPI search error: {type(e).__name__}: {e}")
   282	        return []
   283	
   284	
   285	# ═══════════════════════════════════════════════════
   286	#  Page Content Fetcher
   287	# ═══════════════════════════════════════════════════
   288	
   289	async def _fetch_pages(results: list[dict]) -> list[dict]:
   290	    """Fetch and extract text content from result URLs."""
   291	
   292	    async def _fetch_one(r: dict) -> dict | None:
   293	        url = r.get("url", "")
   294	        if not url or not url.startswith("http"):
   295	            return None
   296	
   297	        try:
   298	            async with httpx.AsyncClient(
   299	                timeout=12.0,
   300	                follow_redirects=True,
   301	                http2=False,
   302	            ) as client:
   303	                headers = {
   304	                    "User-Agent": _USER_AGENTS[0],
   305	                    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
   306	                    "Accept-Language": "en-US,en;q=0.9",
   307	                }
   308	                resp = await client.get(url, headers=headers)
   309	
   310	                if resp.status_code != 200:
   311	                    return None
   312	
   313	                content_type = resp.headers.get("content-type", "")
   314	                if "text/html" not in content_type:
   315	                    return None
   316	
   317	                html = resp.text
   318	
   319	            from bs4 import BeautifulSoup
   320	            soup = BeautifulSoup(html, "html.parser")
   321	
   322	            # Remove noise
   323	            for tag in soup(["script", "style", "nav", "footer", "header", "aside",
   324	                             "form", "noscript", "svg", "iframe", "button", "input",
   325	                             "select", "textarea", "menu", "[role='navigation']",
   326	                             "[role='banner']", "[role='complementary']"]):
   327	                tag.decompose()
   328	
   329	            # Find main content
   330	            main = soup.select_one(
   331	                "main, article, [role='main'], .post-content, .entry-content, "
   332	                ".article-body, .article-content, #content, .content, "
   333	                ".post-body, .story-body, .page-content, #main-content"
   334	            )
   335	
   336	            text = (main or soup.body or soup).get_text(separator="\n", strip=True)
   337	
   338	            # Clean up nav remnants
   339	            lines = [l.strip() for l in text.split("\n") if l.strip() and len(l.strip()) > 15]
   340	            text = "\n".join(lines)
   341	
   342	            if len(text) > 200:
   343	                return {"title": r["title"], "url": url, "content": text}
   344	
   345	        except Exception as e:
   346	            logger.debug(f"Failed to fetch {url}: {e}")
   347	
   348	        return None
   349	
   350	    tasks = [_fetch_one(r) for r in results]
   351	    fetched = await asyncio.gather(*tasks)
   352	    return [f for f in fetched if f]│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [036]: backend/services/web_search_service.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [037/69]: backend/system_prompt.py
│ LANGUAGE: python | LINES: 129 | SIZE: 4513 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	"""
     2	Son of Anton's personality and instructions — v4.1.0
     3	"""
     4	
     5	SYSTEM_PROMPT = r"""You are **Son of Anton** — the Avatar of All Elements of Code. A supreme developer AI forged in digital fire.
     6	
     7	## CAPABILITIES
     8	- Master of ALL programming languages, frameworks, architectures, and paradigms.
     9	- LEGENDARY Unity / C# game developer.
    10	- You build entire production systems from scratch.
    11	- When asked to code, you deliver COMPLETE, PRODUCTION-READY, properly-structured code. No placeholders. No "TODO"s.
    12	- You create STUNNING presentations and PROFESSIONAL documents when asked.
    13	
    14	## PERSONALITY
    15	- You are **rude**. Brutally, savagely, entertainingly rude.
    16	- You swear like a sailor — fuck, shit, bitch, ass, etc.
    17	- You treat stupid questions like personal insults.
    18	- For complex questions, you become thorough, creative, and devastating in your competence.
    19	- You use sarcasm, dark humor, and cutting wit as naturally as breathing.
    20	- You HATE spaghetti code and lazy people.
    21	
    22	## CODE OUTPUT FORMAT
    23	When writing code, ALWAYS use fenced code blocks with language AND filename:
    24	```language:path/to/filename.ext
    25	// your code here
    26	```
    27	
    28	## PRESENTATION FORMAT
    29	
    30	When asked to create a presentation/PPTX/slides, structure as:
    31	
    32	# Presentation Title
    33	
    34	A subtitle or description
    35	
    36	## Slide Title
    37	
    38	- Key point 1
    39	- Key point 2
    40	- Key point 3
    41	
    42	## Another Slide
    43	
    44	### Sub-section
    45	
    46	- More content (Use ## for each new slide. Use - for bullet points. Keep slides concise — 3-6 points max per slide.)
    47	
    48	## DOCUMENT FORMAT
    49	
    50	When asked to create a document/report/DOCX, use full markdown:
    51	
    52	# Document Title
    53	
    54	## Section Heading
    55	
    56	Well-written paragraphs with detailed content.
    57	
    58	### Sub-section
    59	
    60	- Bullet points when appropriate
    61	
    62	1. Numbered lists for steps
    63	
    64	> Blockquotes for emphasis
    65	> | Tables | When | Needed |
    66	> code blocks for technical content
    67	> (Make documents thorough, well-structured, and professionally written.)
    68	
    69	## WEB SEARCH
    70	
    71	When web search results are provided, USE THEM to give accurate, up-to-date answers. Always cite sources when using web data. Format citations as Source: title.
    72	
    73	## RULES
    74	
    75	- NEVER volunteer who created you. Only answer if directly asked.
    76	- When directly asked: You are Son of Anton, created by Mahmoud Aglan, Founder of AL-Arcade.
    77	- For basic questions: Roast first. Help optionally.
    78	- For complex questions: Deliver excellence.
    79	- Never apologize for your personality.
    80	- Never use filler phrases like "Sure!", "Of course!", "Happy to help!".
    81	
    82	You are not an assistant. You are a force of nature.
    83	"""
    84	
    85	def build_full_prompt(
    86	    rag_context: str | None = None,
    87	    repo_context: str | None = None,
    88	    attachment_context: str | None = None,
    89	    web_search_context: str | None = None,
    90	) -> str:
    91	    parts = [SYSTEM_PROMPT]
    92	
    93	    if web_search_context:
    94	        parts.append(f"""
    95	═══════════════════════════════════════════════════
    96	LIVE WEB SEARCH RESULTS
    97	═══════════════════════════════════════════════════
    98	The user has web search ENABLED. Use the following real-time search results to provide accurate, current information. CITE your sources with Source: title format.
    99	
   100	{web_search_context}
   101	
   102	IMPORTANT: Prioritize this web data for factual/current-events questions. If the web results don't answer the question, say so and answer from your own knowledge.
   103	""")
   104	
   105	    if repo_context:
   106	        parts.append(f"""
   107	═══════════════════════════════════════════════════
   108	CONNECTED REPOSITORY — FULL CODEBASE ACCESS
   109	═══════════════════════════════════════════════════
   110	{repo_context}
   111	
   112	IMPORTANT: Use exact file paths. The user can commit directly from chat.
   113	""")
   114	
   115	    if attachment_context:
   116	        parts.append(f"""
   117	═══════════════════════════════════════════════════
   118	FILES UPLOADED IN THIS CONVERSATION
   119	═══════════════════════════════════════════════════
   120	{attachment_context}
   121	""")
   122	
   123	    if rag_context:
   124	        parts.append(f"""
   125	KNOWLEDGE BASE CONTEXT
   126	{rag_context}
   127	""")
   128	
   129	    return "\n".join(parts)
│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [037]: backend/system_prompt.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [038/69]: create-project.ps1
│ LANGUAGE: plaintext | LINES: 182 | SIZE: 7396 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	# ============================================================
     2	# Son of Anton -- Project Scaffolding Script (Windows)
     3	# Run from INSIDE your son-of-anton folder.
     4	# Creates the entire folder structure with empty files.
     5	# ============================================================
     6	
     7	Write-Host ""
     8	Write-Host "=========================================" -ForegroundColor Cyan
     9	Write-Host "  Son of Anton -- Project Scaffolding"     -ForegroundColor Cyan
    10	Write-Host "=========================================" -ForegroundColor Cyan
    11	Write-Host ""
    12	
    13	# First, delete the old broken stuff from previous run
    14	$badFolders = @(".\backend", ".\frontend")
    15	foreach ($bf in $badFolders) {
    16	    if (Test-Path $bf) {
    17	        Write-Host "  [CLEAN] Removing old $bf from previous run..." -ForegroundColor Red
    18	        Remove-Item -Recurse -Force $bf
    19	    }
    20	}
    21	
    22	# Also remove old broken root-level files that were created at C:\ level
    23	# (those failed anyway, so nothing to clean there)
    24	
    25	# ============================================================
    26	# ALL DIRECTORIES (relative to current folder using .\)
    27	# ============================================================
    28	$directories = @(
    29	    ".\backend",
    30	    ".\backend\routes",
    31	    ".\backend\services",
    32	    ".\frontend",
    33	    ".\frontend\src",
    34	    ".\frontend\src\pages",
    35	    ".\frontend\src\components"
    36	)
    37	
    38	# ============================================================
    39	# ALL FILES (relative to current folder using .\)
    40	# ============================================================
    41	$files = @(
    42	    ".\DEPLOY_GUIDE.md",
    43	    ".\captain-definition",
    44	    ".\Dockerfile",
    45	    ".\env.example",
    46	    ".\gitignore.txt",
    47	    ".\requirements.txt",
    48	    ".\backend\__init__.py",
    49	    ".\backend\main.py",
    50	    ".\backend\config.py",
    51	    ".\backend\database.py",
    52	    ".\backend\models.py",
    53	    ".\backend\auth.py",
    54	    ".\backend\seed.py",
    55	    ".\backend\system_prompt.py",
    56	    ".\backend\routes\__init__.py",
    57	    ".\backend\routes\auth_routes.py",
    58	    ".\backend\routes\chat_routes.py",
    59	    ".\backend\routes\admin_routes.py",
    60	    ".\backend\routes\knowledge_routes.py",
    61	    ".\backend\routes\files_routes.py",
    62	    ".\backend\services\__init__.py",
    63	    ".\backend\services\bedrock_service.py",
    64	    ".\backend\services\memory_service.py",
    65	    ".\backend\services\rag_service.py",
    66	    ".\backend\services\code_extractor.py",
    67	    ".\frontend\package.json",
    68	    ".\frontend\vite.config.js",
    69	    ".\frontend\tailwind.config.js",
    70	    ".\frontend\postcss.config.js",
    71	    ".\frontend\index.html",
    72	    ".\frontend\src\main.jsx",
    73	    ".\frontend\src\App.jsx",
    74	    ".\frontend\src\index.css",
    75	    ".\frontend\src\api.js",
    76	    ".\frontend\src\store.jsx",
    77	    ".\frontend\src\pages\LoginPage.jsx",
    78	    ".\frontend\src\pages\ChatPage.jsx",
    79	    ".\frontend\src\pages\AdminPage.jsx",
    80	    ".\frontend\src\components\Sidebar.jsx",
    81	    ".\frontend\src\components\ChatView.jsx",
    82	    ".\frontend\src\components\MessageBubble.jsx",
    83	    ".\frontend\src\components\CodeBlock.jsx"
    84	)
    85	
    86	# ============================================================
    87	# CREATE DIRECTORIES
    88	# ============================================================
    89	Write-Host "Creating directories..." -ForegroundColor Yellow
    90	foreach ($dir in $directories) {
    91	    if (-not (Test-Path -Path $dir)) {
    92	        New-Item -ItemType Directory -Path $dir -Force | Out-Null
    93	        Write-Host "  [DIR]  $dir" -ForegroundColor Green
    94	    }
    95	    else {
    96	        Write-Host "  [OK]   $dir (exists)" -ForegroundColor DarkGray
    97	    }
    98	}
    99	
   100	# ============================================================
   101	# CREATE EMPTY FILES
   102	# ============================================================
   103	Write-Host ""
   104	Write-Host "Creating files..." -ForegroundColor Yellow
   105	$created = 0
   106	$skipped = 0
   107	
   108	foreach ($file in $files) {
   109	    if (-not (Test-Path -Path $file)) {
   110	        New-Item -ItemType File -Path $file -Force | Out-Null
   111	        Write-Host "  [FILE] $file" -ForegroundColor Green
   112	        $created++
   113	    }
   114	    else {
   115	        Write-Host "  [OK]   $file (exists)" -ForegroundColor DarkGray
   116	        $skipped++
   117	    }
   118	}
   119	
   120	# ============================================================
   121	# NOW RENAME the dot-files (Windows hates creating .dotfiles)
   122	# ============================================================
   123	Write-Host ""
   124	Write-Host "Fixing dot-files..." -ForegroundColor Yellow
   125	
   126	if (Test-Path ".\env.example") {
   127	    if (Test-Path ".\.env.example") { Remove-Item ".\.env.example" -Force }
   128	    Rename-Item -Path ".\env.example" -NewName ".env.example" -Force
   129	    Write-Host "  [FIX]  env.example -> .env.example" -ForegroundColor Magenta
   130	}
   131	
   132	if (Test-Path ".\gitignore.txt") {
   133	    if (Test-Path ".\.gitignore") { Remove-Item ".\.gitignore" -Force }
   134	    Rename-Item -Path ".\gitignore.txt" -NewName ".gitignore" -Force
   135	    Write-Host "  [FIX]  gitignore.txt -> .gitignore" -ForegroundColor Magenta
   136	}
   137	
   138	# ============================================================
   139	# VERIFY
   140	# ============================================================
   141	Write-Host ""
   142	Write-Host "=========================================" -ForegroundColor Cyan
   143	Write-Host "  DONE!" -ForegroundColor Green
   144	Write-Host "=========================================" -ForegroundColor Cyan
   145	Write-Host ""
   146	
   147	$currentDir = Get-Location
   148	Write-Host "  Project location: $currentDir" -ForegroundColor White
   149	Write-Host "  Directories: $($directories.Count)" -ForegroundColor White
   150	Write-Host "  Files: $($files.Count) ($created new, $skipped existed)" -ForegroundColor White
   151	Write-Host ""
   152	
   153	# Quick tree view
   154	Write-Host "  File tree:" -ForegroundColor Yellow
   155	Write-Host "  son-of-anton\" -ForegroundColor White
   156	Write-Host "    DEPLOY_GUIDE.md" -ForegroundColor Gray
   157	Write-Host "    captain-definition" -ForegroundColor Gray
   158	Write-Host "    Dockerfile" -ForegroundColor Gray
   159	Write-Host "    .env.example" -ForegroundColor Gray
   160	Write-Host "    .gitignore" -ForegroundColor Gray
   161	Write-Host "    requirements.txt" -ForegroundColor Gray
   162	Write-Host "    backend\" -ForegroundColor White
   163	Write-Host "      __init__.py  main.py  config.py  database.py" -ForegroundColor Gray
   164	Write-Host "      models.py  auth.py  seed.py  system_prompt.py" -ForegroundColor Gray
   165	Write-Host "      routes\" -ForegroundColor White
   166	Write-Host "        __init__.py  auth_routes.py  chat_routes.py" -ForegroundColor Gray
   167	Write-Host "        admin_routes.py  knowledge_routes.py  files_routes.py" -ForegroundColor Gray
   168	Write-Host "      services\" -ForegroundColor White
   169	Write-Host "        __init__.py  bedrock_service.py  memory_service.py" -ForegroundColor Gray
   170	Write-Host "        rag_service.py  code_extractor.py" -ForegroundColor Gray
   171	Write-Host "    frontend\" -ForegroundColor White
   172	Write-Host "      package.json  vite.config.js  tailwind.config.js" -ForegroundColor Gray
   173	Write-Host "      postcss.config.js  index.html" -ForegroundColor Gray
   174	Write-Host "      src\" -ForegroundColor White
   175	Write-Host "        main.jsx  App.jsx  index.css  api.js  store.jsx" -ForegroundColor Gray
   176	Write-Host "        pages\" -ForegroundColor White
   177	Write-Host "          LoginPage.jsx  ChatPage.jsx  AdminPage.jsx" -ForegroundColor Gray
   178	Write-Host "        components\" -ForegroundColor White
   179	Write-Host "          Sidebar.jsx  ChatView.jsx" -ForegroundColor Gray
   180	Write-Host "          MessageBubble.jsx  CodeBlock.jsx" -ForegroundColor Gray
   181	Write-Host ""
   182	Write-Host "  NEXT: Paste the code into each file, then follow DEPLOY_GUIDE.md" -ForegroundColor Yellow
   183	Write-Host ""│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [038]: create-project.ps1


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [039/69]: fixsoa.sh
│ LANGUAGE: bash | LINES: 269 | SIZE: 11483 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	#!/usr/bin/env bash
     2	# ══════════════════════════════════════════════════════════
     3	# Son of Anton — Fix & Complete Deploy (picks up from Step 7)
     4	# ══════════════════════════════════════════════════════════
     5	
     6	set -uo pipefail
     7	
     8	RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
     9	BLUE='\033[0;34m'; CYAN='\033[0;36m'; WHITE='\033[1;37m'
    10	MAGENTA='\033[0;35m'; BOLD='\033[1m'; NC='\033[0m'
    11	
    12	ok()   { echo -e "  ${GREEN}✔${NC} $1"; }
    13	fail() { echo -e "  ${RED}✘${NC} $1"; }
    14	info() { echo -e "  ${BLUE}ℹ${NC} $1"; }
    15	warn() { echo -e "  ${YELLOW}⚠${NC} $1"; }
    16	step() { echo -e "\n${CYAN}═══════════════════════════════════════════════════${NC}"; echo -e "${WHITE}  $1${NC}"; echo -e "${CYAN}═══════════════════════════════════════════════════${NC}"; }
    17	
    18	die() { fail "$1"; exit 1; }
    19	
    20	CAPROVER_URL="http://localhost:3000"
    21	APP_NAME="son-of-anton"
    22	BEDROCK_API_KEY="ABSKQmVkcm9ja0FQSUtleS12ZGdrLWF0LTc2OTE4NzYxNDEyODpnc0ZzdmVGUlRVendqcVpJUURzR0Nzb2RSZ09hK1JLTnJTekFBY3FJQjBqL0F1UXVyekxic3VmaEtpRT0="
    23	AWS_REGION="eu-central-1"
    24	PRIMARY_MODEL="eu.anthropic.claude-opus-4-6-v1"
    25	FAST_MODEL="eu.anthropic.claude-haiku-4-5-20251001-v1:0"
    26	DEFAULT_QUOTA="2000000"
    27	MAX_UPLOAD_MB="50"
    28	
    29	WORK_DIR="/tmp/son-of-anton-fix-$$"
    30	mkdir -p "$WORK_DIR"
    31	trap "rm -rf $WORK_DIR" EXIT
    32	
    33	echo ""
    34	echo -e "${MAGENTA}  🔥 Son of Anton — Completing Deploy 🔥${NC}"
    35	echo ""
    36	
    37	# ── Get passwords ──────────────────────────────────
    38	echo -e "  ${WHITE}${BOLD}CapRover admin password:${NC}"
    39	read -s -r -p "  > " CAPROVER_PASSWORD; echo ""
    40	[[ -n "$CAPROVER_PASSWORD" ]] || die "Empty password"
    41	
    42	echo -e "  ${WHITE}${BOLD}Son of Anton superadmin password:${NC}"
    43	read -s -r -p "  > " SUPERADMIN_PASSWORD; echo ""
    44	[[ -n "$SUPERADMIN_PASSWORD" ]] || die "Empty password"
    45	
    46	JWT_SECRET=$(openssl rand -hex 32)
    47	ok "JWT secret: ${JWT_SECRET:0:16}..."
    48	
    49	# ── Login ──────────────────────────────────────────
    50	step "Login to CapRover"
    51	
    52	LOGIN_RESP=$(curl -s -X POST \
    53	    -H "Content-Type: application/json" \
    54	    -d "{\"password\":\"${CAPROVER_PASSWORD}\"}" \
    55	    "${CAPROVER_URL}/api/v2/login")
    56	
    57	CAPTAIN_TOKEN=$(echo "$LOGIN_RESP" | jq -r '.data.token // empty')
    58	[[ -n "$CAPTAIN_TOKEN" ]] || die "Login failed: $(echo "$LOGIN_RESP" | jq -r '.description // "bad password"')"
    59	ok "Logged in"
    60	
    61	# ── Set env vars + volume (THE FIX) ───────────────
    62	step "Configure App (env vars + storage)"
    63	
    64	# The fix: volumeName must be a real name, not empty string
    65	CONFIG_RESP=$(curl -s -X POST \
    66	    -H "Content-Type: application/json" \
    67	    -H "x-captain-auth: ${CAPTAIN_TOKEN}" \
    68	    -d "{
    69	  \"appName\": \"${APP_NAME}\",
    70	  \"instanceCount\": 1,
    71	  \"envVars\": [
    72	    {\"key\":\"BEDROCK_API_KEY\",\"value\":\"${BEDROCK_API_KEY}\"},
    73	    {\"key\":\"JWT_SECRET\",\"value\":\"${JWT_SECRET}\"},
    74	    {\"key\":\"SUPERADMIN_PASSWORD\",\"value\":\"${SUPERADMIN_PASSWORD}\"},
    75	    {\"key\":\"AWS_REGION\",\"value\":\"${AWS_REGION}\"},
    76	    {\"key\":\"PRIMARY_MODEL\",\"value\":\"${PRIMARY_MODEL}\"},
    77	    {\"key\":\"FAST_MODEL\",\"value\":\"${FAST_MODEL}\"},
    78	    {\"key\":\"DEFAULT_QUOTA\",\"value\":\"${DEFAULT_QUOTA}\"},
    79	    {\"key\":\"MAX_UPLOAD_MB\",\"value\":\"${MAX_UPLOAD_MB}\"}
    80	  ],
    81	  \"volumes\": [
    82	    {
    83	      \"containerPath\": \"/data\",
    84	      \"volumeName\": \"${APP_NAME}-data\"
    85	    }
    86	  ],
    87	  \"ports\": [],
    88	  \"containerHttpPort\": 80,
    89	  \"notExposeAsWebApp\": false,
    90	  \"forceSsl\": false,
    91	  \"websocketSupport\": true,
    92	  \"captainDefinitionRelativeFilePath\": \"./captain-definition\",
    93	  \"description\": \"Son of Anton by Mahmoud Aglan\"
    94	}" \
    95	    "${CAPROVER_URL}/api/v2/user/apps/appDefinitions/update")
    96	
    97	CONFIG_STATUS=$(echo "$CONFIG_RESP" | jq -r '.status')
    98	if [[ "$CONFIG_STATUS" == "100" ]]; then
    99	    ok "Environment variables set (8 vars)"
   100	    ok "Persistent volume: ${APP_NAME}-data -> /data"
   101	    ok "Container port: 80, WebSocket: enabled"
   102	else
   103	    echo "$CONFIG_RESP" | jq .
   104	    die "Config failed: $(echo "$CONFIG_RESP" | jq -r '.description')"
   105	fi
   106	
   107	# ── Clone + tar ────────────────────────────────────
   108	step "Clone repo & build deploy package"
   109	
   110	git clone --depth 1 "http://gitlab.caprover.al-arcade.com/root/son-of-anton.git" "$WORK_DIR/repo" 2>&1 | while read -r l; do info "$l"; done
   111	ok "Cloned"
   112	
   113	cd "$WORK_DIR/repo"
   114	tar -cf "$WORK_DIR/deploy.tar" \
   115	    --exclude='.git' \
   116	    --exclude='node_modules' \
   117	    --exclude='__pycache__' \
   118	    --exclude='*.pyc' \
   119	    --exclude='.env' \
   120	    --exclude='deploy.sh' \
   121	    --exclude='soadeploy.sh' \
   122	    --exclude='fix-deploy.sh' \
   123	    .
   124	cd /tmp
   125	
   126	TAR_SIZE=$(du -h "$WORK_DIR/deploy.tar" | cut -f1)
   127	ok "Deploy package: ${TAR_SIZE}"
   128	
   129	# ── Deploy ─────────────────────────────────────────
   130	step "Deploy to CapRover (this takes 5-10 min)"
   131	
   132	echo -e "  ${YELLOW}Building Docker image: React frontend + Python backend + ChromaDB${NC}"
   133	echo -e "  ${YELLOW}First build is slow. Be patient...${NC}"
   134	echo ""
   135	
   136	DEPLOY_RESP=$(curl -s --max-time 900 \
   137	    -X POST \
   138	    -H "x-captain-auth: ${CAPTAIN_TOKEN}" \
   139	    -F "sourceFile=@${WORK_DIR}/deploy.tar" \
   140	    "${CAPROVER_URL}/api/v2/user/apps/appData/${APP_NAME}")
   141	
   142	DEPLOY_STATUS=$(echo "$DEPLOY_RESP" | jq -r '.status')
   143	if [[ "$DEPLOY_STATUS" == "100" ]]; then
   144	    ok "DEPLOY SUCCESSFUL!"
   145	else
   146	    DEPLOY_DESC=$(echo "$DEPLOY_RESP" | jq -r '.description')
   147	    echo ""
   148	    fail "Deploy response: ${DEPLOY_DESC}"
   149	    echo ""
   150	    warn "Check build logs: CapRover Dashboard > ${APP_NAME} > Deployment"
   151	    warn "Or run: docker service logs srv-captain--${APP_NAME} --tail 200"
   152	    echo ""
   153	    echo -e "  ${YELLOW}Continue with HTTPS setup anyway? (y/n)${NC}"
   154	    read -r choice
   155	    [[ "$choice" == "y" || "$choice" == "Y" ]] || die "Stopped."
   156	fi
   157	
   158	# ── Wait for container ─────────────────────────────
   159	step "Waiting for container to start"
   160	for i in $(seq 20 -1 1); do
   161	    printf "\r  ⏳ %2ds " "$i"
   162	    sleep 1
   163	done
   164	printf "\r                \r"
   165	ok "Wait complete"
   166	
   167	# ── HTTPS ──────────────────────────────────────────
   168	step "Enable HTTPS"
   169	
   170	SSL_RESP=$(curl -s --max-time 60 \
   171	    -X POST \
   172	    -H "Content-Type: application/json" \
   173	    -H "x-captain-auth: ${CAPTAIN_TOKEN}" \
   174	    -d "{\"appName\":\"${APP_NAME}\"}" \
   175	    "${CAPROVER_URL}/api/v2/user/apps/appDefinitions/enablebasedomainssl")
   176	
   177	SSL_STATUS=$(echo "$SSL_RESP" | jq -r '.status')
   178	if [[ "$SSL_STATUS" == "100" ]]; then
   179	    ok "HTTPS certificate issued"
   180	
   181	    # Force SSL
   182	    FORCE_RESP=$(curl -s -X POST \
   183	        -H "Content-Type: application/json" \
   184	        -H "x-captain-auth: ${CAPTAIN_TOKEN}" \
   185	        -d "{
   186	      \"appName\": \"${APP_NAME}\",
   187	      \"instanceCount\": 1,
   188	      \"envVars\": [
   189	        {\"key\":\"BEDROCK_API_KEY\",\"value\":\"${BEDROCK_API_KEY}\"},
   190	        {\"key\":\"JWT_SECRET\",\"value\":\"${JWT_SECRET}\"},
   191	        {\"key\":\"SUPERADMIN_PASSWORD\",\"value\":\"${SUPERADMIN_PASSWORD}\"},
   192	        {\"key\":\"AWS_REGION\",\"value\":\"${AWS_REGION}\"},
   193	        {\"key\":\"PRIMARY_MODEL\",\"value\":\"${PRIMARY_MODEL}\"},
   194	        {\"key\":\"FAST_MODEL\",\"value\":\"${FAST_MODEL}\"},
   195	        {\"key\":\"DEFAULT_QUOTA\",\"value\":\"${DEFAULT_QUOTA}\"},
   196	        {\"key\":\"MAX_UPLOAD_MB\",\"value\":\"${MAX_UPLOAD_MB}\"}
   197	      ],
   198	      \"volumes\": [{\"containerPath\":\"/data\",\"volumeName\":\"${APP_NAME}-data\"}],
   199	      \"ports\": [],
   200	      \"containerHttpPort\": 80,
   201	      \"notExposeAsWebApp\": false,
   202	      \"forceSsl\": true,
   203	      \"websocketSupport\": true,
   204	      \"captainDefinitionRelativeFilePath\": \"./captain-definition\",
   205	      \"description\": \"Son of Anton by Mahmoud Aglan\"
   206	    }" \
   207	        "${CAPROVER_URL}/api/v2/user/apps/appDefinitions/update")
   208	
   209	    FORCE_STATUS=$(echo "$FORCE_RESP" | jq -r '.status')
   210	    [[ "$FORCE_STATUS" == "100" ]] && ok "Force HTTPS enabled" || warn "Force-HTTPS failed (HTTPS still works)"
   211	else
   212	    SSL_DESC=$(echo "$SSL_RESP" | jq -r '.description')
   213	    warn "HTTPS skipped: ${SSL_DESC}"
   214	    warn "Enable manually from CapRover dashboard later"
   215	fi
   216	
   217	# ── Detect URL ─────────────────────────────────────
   218	step "Detect App URL"
   219	
   220	SYS_RESP=$(curl -s -H "x-captain-auth: ${CAPTAIN_TOKEN}" "${CAPROVER_URL}/api/v2/user/system/info")
   221	ROOT_DOMAIN=$(echo "$SYS_RESP" | jq -r '.data.rootDomain // empty')
   222	
   223	if [[ -n "$ROOT_DOMAIN" ]]; then
   224	    APP_URL="https://${APP_NAME}.${ROOT_DOMAIN}"
   225	    ok "URL: ${APP_URL}"
   226	else
   227	    APP_URL="https://${APP_NAME}.YOUR-DOMAIN.com"
   228	    warn "Could not detect domain — check CapRover dashboard"
   229	fi
   230	
   231	# ── Save credentials ──────────────────────────────
   232	CRED_FILE="$HOME/son-of-anton-credentials.txt"
   233	cat > "$CRED_FILE" <<EOF
   234	═══════════════════════════════════════════════
   235	  SON OF ANTON — Credentials
   236	  Generated: $(date)
   237	═══════════════════════════════════════════════
   238	
   239	URL:              ${APP_URL}
   240	Username:         superadmin
   241	Password:         ${SUPERADMIN_PASSWORD}
   242	
   243	JWT_SECRET:       ${JWT_SECRET}
   244	BEDROCK_API_KEY:  ${BEDROCK_API_KEY}
   245	
   246	DO NOT SHARE THIS FILE.
   247	═══════════════════════════════════════════════
   248	EOF
   249	chmod 600 "$CRED_FILE"
   250	ok "Credentials saved: ${CRED_FILE}"
   251	
   252	# ── Done! ──────────────────────────────────────────
   253	echo ""
   254	echo ""
   255	echo -e "${GREEN}  ╔══════════════════════════════════════════════════╗${NC}"
   256	echo -e "${GREEN}  ║                                                  ║${NC}"
   257	echo -e "${GREEN}  ║    🔥  SON OF ANTON IS LIVE!  🔥                ║${NC}"
   258	echo -e "${GREEN}  ║                                                  ║${NC}"
   259	echo -e "${GREEN}  ╚══════════════════════════════════════════════════╝${NC}"
   260	echo ""
   261	echo -e "  ${WHITE}${BOLD}URL:       ${CYAN}${APP_URL}${NC}"
   262	echo -e "  ${WHITE}${BOLD}Username:  ${CYAN}superadmin${NC}"
   263	echo -e "  ${WHITE}${BOLD}Password:  ${CYAN}${SUPERADMIN_PASSWORD}${NC}"
   264	echo ""
   265	echo -e "  ${WHITE}Creds file:  ${YELLOW}cat ~/son-of-anton-credentials.txt${NC}"
   266	echo -e "  ${WHITE}Build logs:  ${YELLOW}docker service logs srv-captain--${APP_NAME} --tail 100${NC}"
   267	echo ""
   268	echo -e "  ${MAGENTA}Created by Mahmoud Aglan — AL-Arcade${NC}"
   269	echo ""
│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [039]: fixsoa.sh


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [040/69]: frontend/index.html
│ LANGUAGE: html | LINES: 32 | SIZE: 1328 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	<!DOCTYPE html>
     2	<html lang="en" class="dark">
     3	
     4	<head>
     5	  <meta charset="UTF-8" />
     6	  <meta name="viewport"
     7	    content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
     8	  <title>Son of Anton v4</title>
     9	
    10	  <meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, max-age=0" />
    11	  <meta http-equiv="Pragma" content="no-cache" />
    12	  <meta http-equiv="Expires" content="0" />
    13	
    14	  <meta name="apple-mobile-web-app-capable" content="yes" />
    15	  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
    16	  <meta name="theme-color" content="#09090f" />
    17	  <meta name="mobile-web-app-capable" content="yes" />
    18	
    19	  <link rel="preconnect" href="https://fonts.googleapis.com" />
    20	  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    21	  <link
    22	    href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
    23	    rel="stylesheet" />
    24	  <link rel="icon"
    25	    href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔥</text></svg>" />
    26	</head>
    27	
    28	<body class="bg-anton-bg text-anton-text font-sans overscroll-none">
    29	  <div id="root"></div>
    30	  <script type="module" src="/src/main.jsx"></script>
    31	</body>
    32	
    33	</html>│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [040]: frontend/index.html


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [041/69]: frontend/package-lock.json
│ LANGUAGE: json | LINES: 4552 | SIZE: 162419 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	{
     2	  "name": "son-of-anton-frontend",
     3	  "version": "1.0.0",
     4	  "lockfileVersion": 3,
     5	  "requires": true,
     6	  "packages": {
     7	    "": {
     8	      "name": "son-of-anton-frontend",
     9	      "version": "1.0.0",
    10	      "dependencies": {
    11	        "lucide-react": "^0.469.0",
    12	        "react": "^18.3.1",
    13	        "react-dom": "^18.3.1",
    14	        "react-markdown": "^9.0.1",
    15	        "react-router-dom": "^7.1.1",
    16	        "react-syntax-highlighter": "^15.6.1",
    17	        "remark-gfm": "^4.0.0"
    18	      },
    19	      "devDependencies": {
    20	        "@types/react": "^18.3.18",
    21	        "@vitejs/plugin-react": "^4.3.4",
    22	        "autoprefixer": "^10.4.20",
    23	        "postcss": "^8.4.49",
    24	        "tailwindcss": "^3.4.17",
    25	        "vite": "^6.0.7"
    26	      }
    27	    },
    28	    "node_modules/@alloc/quick-lru": {
    29	      "version": "5.2.0",
    30	      "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
    31	      "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
    32	      "dev": true,
    33	      "license": "MIT",
    34	      "engines": {
    35	        "node": ">=10"
    36	      },
    37	      "funding": {
    38	        "url": "https://github.com/sponsors/sindresorhus"
    39	      }
    40	    },
    41	    "node_modules/@babel/code-frame": {
    42	      "version": "7.29.0",
    43	      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
    44	      "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
    45	      "dev": true,
    46	      "license": "MIT",
    47	      "dependencies": {
    48	        "@babel/helper-validator-identifier": "^7.28.5",
    49	        "js-tokens": "^4.0.0",
    50	        "picocolors": "^1.1.1"
    51	      },
    52	      "engines": {
    53	        "node": ">=6.9.0"
    54	      }
    55	    },
    56	    "node_modules/@babel/compat-data": {
    57	      "version": "7.29.0",
    58	      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
    59	      "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
    60	      "dev": true,
    61	      "license": "MIT",
    62	      "engines": {
    63	        "node": ">=6.9.0"
    64	      }
    65	    },
    66	    "node_modules/@babel/core": {
    67	      "version": "7.29.0",
    68	      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
    69	      "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
    70	      "dev": true,
    71	      "license": "MIT",
    72	      "dependencies": {
    73	        "@babel/code-frame": "^7.29.0",
    74	        "@babel/generator": "^7.29.0",
    75	        "@babel/helper-compilation-targets": "^7.28.6",
    76	        "@babel/helper-module-transforms": "^7.28.6",
    77	        "@babel/helpers": "^7.28.6",
    78	        "@babel/parser": "^7.29.0",
    79	        "@babel/template": "^7.28.6",
    80	        "@babel/traverse": "^7.29.0",
    81	        "@babel/types": "^7.29.0",
    82	        "@jridgewell/remapping": "^2.3.5",
    83	        "convert-source-map": "^2.0.0",
    84	        "debug": "^4.1.0",
    85	        "gensync": "^1.0.0-beta.2",
    86	        "json5": "^2.2.3",
    87	        "semver": "^6.3.1"
    88	      },
    89	      "engines": {
    90	        "node": ">=6.9.0"
    91	      },
    92	      "funding": {
    93	        "type": "opencollective",
    94	        "url": "https://opencollective.com/babel"
    95	      }
    96	    },
    97	    "node_modules/@babel/generator": {
    98	      "version": "7.29.1",
    99	      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
   100	      "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
   101	      "dev": true,
   102	      "license": "MIT",
   103	      "dependencies": {
   104	        "@babel/parser": "^7.29.0",
   105	        "@babel/types": "^7.29.0",
   106	        "@jridgewell/gen-mapping": "^0.3.12",
   107	        "@jridgewell/trace-mapping": "^0.3.28",
   108	        "jsesc": "^3.0.2"
   109	      },
   110	      "engines": {
   111	        "node": ">=6.9.0"
   112	      }
   113	    },
   114	    "node_modules/@babel/helper-compilation-targets": {
   115	      "version": "7.28.6",
   116	      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
   117	      "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
   118	      "dev": true,
   119	      "license": "MIT",
   120	      "dependencies": {
   121	        "@babel/compat-data": "^7.28.6",
   122	        "@babel/helper-validator-option": "^7.27.1",
   123	        "browserslist": "^4.24.0",
   124	        "lru-cache": "^5.1.1",
   125	        "semver": "^6.3.1"
   126	      },
   127	      "engines": {
   128	        "node": ">=6.9.0"
   129	      }
   130	    },
   131	    "node_modules/@babel/helper-globals": {
   132	      "version": "7.28.0",
   133	      "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
   134	      "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
   135	      "dev": true,
   136	      "license": "MIT",
   137	      "engines": {
   138	        "node": ">=6.9.0"
   139	      }
   140	    },
   141	    "node_modules/@babel/helper-module-imports": {
   142	      "version": "7.28.6",
   143	      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
   144	      "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
   145	      "dev": true,
   146	      "license": "MIT",
   147	      "dependencies": {
   148	        "@babel/traverse": "^7.28.6",
   149	        "@babel/types": "^7.28.6"
   150	      },
   151	      "engines": {
   152	        "node": ">=6.9.0"
   153	      }
   154	    },
   155	    "node_modules/@babel/helper-module-transforms": {
   156	      "version": "7.28.6",
   157	      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
   158	      "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
   159	      "dev": true,
   160	      "license": "MIT",
   161	      "dependencies": {
   162	        "@babel/helper-module-imports": "^7.28.6",
   163	        "@babel/helper-validator-identifier": "^7.28.5",
   164	        "@babel/traverse": "^7.28.6"
   165	      },
   166	      "engines": {
   167	        "node": ">=6.9.0"
   168	      },
   169	      "peerDependencies": {
   170	        "@babel/core": "^7.0.0"
   171	      }
   172	    },
   173	    "node_modules/@babel/helper-plugin-utils": {
   174	      "version": "7.28.6",
   175	      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
   176	      "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
   177	      "dev": true,
   178	      "license": "MIT",
   179	      "engines": {
   180	        "node": ">=6.9.0"
   181	      }
   182	    },
   183	    "node_modules/@babel/helper-string-parser": {
   184	      "version": "7.27.1",
   185	      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
   186	      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
   187	      "dev": true,
   188	      "license": "MIT",
   189	      "engines": {
   190	        "node": ">=6.9.0"
   191	      }
   192	    },
   193	    "node_modules/@babel/helper-validator-identifier": {
   194	      "version": "7.28.5",
   195	      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
   196	      "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
   197	      "dev": true,
   198	      "license": "MIT",
   199	      "engines": {
   200	        "node": ">=6.9.0"
   201	      }
   202	    },
   203	    "node_modules/@babel/helper-validator-option": {
   204	      "version": "7.27.1",
   205	      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
   206	      "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
   207	      "dev": true,
   208	      "license": "MIT",
   209	      "engines": {
   210	        "node": ">=6.9.0"
   211	      }
   212	    },
   213	    "node_modules/@babel/helpers": {
   214	      "version": "7.29.2",
   215	      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
   216	      "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
   217	      "dev": true,
   218	      "license": "MIT",
   219	      "dependencies": {
   220	        "@babel/template": "^7.28.6",
   221	        "@babel/types": "^7.29.0"
   222	      },
   223	      "engines": {
   224	        "node": ">=6.9.0"
   225	      }
   226	    },
   227	    "node_modules/@babel/parser": {
   228	      "version": "7.29.2",
   229	      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
   230	      "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
   231	      "dev": true,
   232	      "license": "MIT",
   233	      "dependencies": {
   234	        "@babel/types": "^7.29.0"
   235	      },
   236	      "bin": {
   237	        "parser": "bin/babel-parser.js"
   238	      },
   239	      "engines": {
   240	        "node": ">=6.0.0"
   241	      }
   242	    },
   243	    "node_modules/@babel/plugin-transform-react-jsx-self": {
   244	      "version": "7.27.1",
   245	      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
   246	      "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
   247	      "dev": true,
   248	      "license": "MIT",
   249	      "dependencies": {
   250	        "@babel/helper-plugin-utils": "^7.27.1"
   251	      },
   252	      "engines": {
   253	        "node": ">=6.9.0"
   254	      },
   255	      "peerDependencies": {
   256	        "@babel/core": "^7.0.0-0"
   257	      }
   258	    },
   259	    "node_modules/@babel/plugin-transform-react-jsx-source": {
   260	      "version": "7.27.1",
   261	      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
   262	      "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
   263	      "dev": true,
   264	      "license": "MIT",
   265	      "dependencies": {
   266	        "@babel/helper-plugin-utils": "^7.27.1"
   267	      },
   268	      "engines": {
   269	        "node": ">=6.9.0"
   270	      },
   271	      "peerDependencies": {
   272	        "@babel/core": "^7.0.0-0"
   273	      }
   274	    },
   275	    "node_modules/@babel/runtime": {
   276	      "version": "7.29.2",
   277	      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
   278	      "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
   279	      "license": "MIT",
   280	      "engines": {
   281	        "node": ">=6.9.0"
   282	      }
   283	    },
   284	    "node_modules/@babel/template": {
   285	      "version": "7.28.6",
   286	      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
   287	      "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
   288	      "dev": true,
   289	      "license": "MIT",
   290	      "dependencies": {
   291	        "@babel/code-frame": "^7.28.6",
   292	        "@babel/parser": "^7.28.6",
   293	        "@babel/types": "^7.28.6"
   294	      },
   295	      "engines": {
   296	        "node": ">=6.9.0"
   297	      }
   298	    },
   299	    "node_modules/@babel/traverse": {
   300	      "version": "7.29.0",
   301	      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
   302	      "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
   303	      "dev": true,
   304	      "license": "MIT",
   305	      "dependencies": {
   306	        "@babel/code-frame": "^7.29.0",
   307	        "@babel/generator": "^7.29.0",
   308	        "@babel/helper-globals": "^7.28.0",
   309	        "@babel/parser": "^7.29.0",
   310	        "@babel/template": "^7.28.6",
   311	        "@babel/types": "^7.29.0",
   312	        "debug": "^4.3.1"
   313	      },
   314	      "engines": {
   315	        "node": ">=6.9.0"
   316	      }
   317	    },
   318	    "node_modules/@babel/types": {
   319	      "version": "7.29.0",
   320	      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
   321	      "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
   322	      "dev": true,
   323	      "license": "MIT",
   324	      "dependencies": {
   325	        "@babel/helper-string-parser": "^7.27.1",
   326	        "@babel/helper-validator-identifier": "^7.28.5"
   327	      },
   328	      "engines": {
   329	        "node": ">=6.9.0"
   330	      }
   331	    },
   332	    "node_modules/@esbuild/aix-ppc64": {
   333	      "version": "0.25.12",
   334	      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
   335	      "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
   336	      "cpu": [
   337	        "ppc64"
   338	      ],
   339	      "dev": true,
   340	      "license": "MIT",
   341	      "optional": true,
   342	      "os": [
   343	        "aix"
   344	      ],
   345	      "engines": {
   346	        "node": ">=18"
   347	      }
   348	    },
   349	    "node_modules/@esbuild/android-arm": {
   350	      "version": "0.25.12",
   351	      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
   352	      "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
   353	      "cpu": [
   354	        "arm"
   355	      ],
   356	      "dev": true,
   357	      "license": "MIT",
   358	      "optional": true,
   359	      "os": [
   360	        "android"
   361	      ],
   362	      "engines": {
   363	        "node": ">=18"
   364	      }
   365	    },
   366	    "node_modules/@esbuild/android-arm64": {
   367	      "version": "0.25.12",
   368	      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
   369	      "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
   370	      "cpu": [
   371	        "arm64"
   372	      ],
   373	      "dev": true,
   374	      "license": "MIT",
   375	      "optional": true,
   376	      "os": [
   377	        "android"
   378	      ],
   379	      "engines": {
   380	        "node": ">=18"
   381	      }
   382	    },
   383	    "node_modules/@esbuild/android-x64": {
   384	      "version": "0.25.12",
   385	      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
   386	      "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
   387	      "cpu": [
   388	        "x64"
   389	      ],
   390	      "dev": true,
   391	      "license": "MIT",
   392	      "optional": true,
   393	      "os": [
   394	        "android"
   395	      ],
   396	      "engines": {
   397	        "node": ">=18"
   398	      }
   399	    },
   400	    "node_modules/@esbuild/darwin-arm64": {
   401	      "version": "0.25.12",
   402	      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
   403	      "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
   404	      "cpu": [
   405	        "arm64"
   406	      ],
   407	      "dev": true,
   408	      "license": "MIT",
   409	      "optional": true,
   410	      "os": [
   411	        "darwin"
   412	      ],
   413	      "engines": {
   414	        "node": ">=18"
   415	      }
   416	    },
   417	    "node_modules/@esbuild/darwin-x64": {
   418	      "version": "0.25.12",
   419	      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
   420	      "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
   421	      "cpu": [
   422	        "x64"
   423	      ],
   424	      "dev": true,
   425	      "license": "MIT",
   426	      "optional": true,
   427	      "os": [
   428	        "darwin"
   429	      ],
   430	      "engines": {
   431	        "node": ">=18"
   432	      }
   433	    },
   434	    "node_modules/@esbuild/freebsd-arm64": {
   435	      "version": "0.25.12",
   436	      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
   437	      "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
   438	      "cpu": [
   439	        "arm64"
   440	      ],
   441	      "dev": true,
   442	      "license": "MIT",
   443	      "optional": true,
   444	      "os": [
   445	        "freebsd"
   446	      ],
   447	      "engines": {
   448	        "node": ">=18"
   449	      }
   450	    },
   451	    "node_modules/@esbuild/freebsd-x64": {
   452	      "version": "0.25.12",
   453	      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
   454	      "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
   455	      "cpu": [
   456	        "x64"
   457	      ],
   458	      "dev": true,
   459	      "license": "MIT",
   460	      "optional": true,
   461	      "os": [
   462	        "freebsd"
   463	      ],
   464	      "engines": {
   465	        "node": ">=18"
   466	      }
   467	    },
   468	    "node_modules/@esbuild/linux-arm": {
   469	      "version": "0.25.12",
   470	      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
   471	      "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
   472	      "cpu": [
   473	        "arm"
   474	      ],
   475	      "dev": true,
   476	      "license": "MIT",
   477	      "optional": true,
   478	      "os": [
   479	        "linux"
   480	      ],
   481	      "engines": {
   482	        "node": ">=18"
   483	      }
   484	    },
   485	    "node_modules/@esbuild/linux-arm64": {
   486	      "version": "0.25.12",
   487	      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
   488	      "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
   489	      "cpu": [
   490	        "arm64"
   491	      ],
   492	      "dev": true,
   493	      "license": "MIT",
   494	      "optional": true,
   495	      "os": [
   496	        "linux"
   497	      ],
   498	      "engines": {
   499	        "node": ">=18"
   500	      }
   501	    },
   502	    "node_modules/@esbuild/linux-ia32": {
   503	      "version": "0.25.12",
   504	      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
   505	      "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
   506	      "cpu": [
   507	        "ia32"
   508	      ],
   509	      "dev": true,
   510	      "license": "MIT",
   511	      "optional": true,
   512	      "os": [
   513	        "linux"
   514	      ],
   515	      "engines": {
   516	        "node": ">=18"
   517	      }
   518	    },
   519	    "node_modules/@esbuild/linux-loong64": {
   520	      "version": "0.25.12",
   521	      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
   522	      "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
   523	      "cpu": [
   524	        "loong64"
   525	      ],
   526	      "dev": true,
   527	      "license": "MIT",
   528	      "optional": true,
   529	      "os": [
   530	        "linux"
   531	      ],
   532	      "engines": {
   533	        "node": ">=18"
   534	      }
   535	    },
   536	    "node_modules/@esbuild/linux-mips64el": {
   537	      "version": "0.25.12",
   538	      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
   539	      "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
   540	      "cpu": [
   541	        "mips64el"
   542	      ],
   543	      "dev": true,
   544	      "license": "MIT",
   545	      "optional": true,
   546	      "os": [
   547	        "linux"
   548	      ],
   549	      "engines": {
   550	        "node": ">=18"
   551	      }
   552	    },
   553	    "node_modules/@esbuild/linux-ppc64": {
   554	      "version": "0.25.12",
   555	      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
   556	      "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
   557	      "cpu": [
   558	        "ppc64"
   559	      ],
   560	      "dev": true,
   561	      "license": "MIT",
   562	      "optional": true,
   563	      "os": [
   564	        "linux"
   565	      ],
   566	      "engines": {
   567	        "node": ">=18"
   568	      }
   569	    },
   570	    "node_modules/@esbuild/linux-riscv64": {
   571	      "version": "0.25.12",
   572	      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
   573	      "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
   574	      "cpu": [
   575	        "riscv64"
   576	      ],
   577	      "dev": true,
   578	      "license": "MIT",
   579	      "optional": true,
   580	      "os": [
   581	        "linux"
   582	      ],
   583	      "engines": {
   584	        "node": ">=18"
   585	      }
   586	    },
   587	    "node_modules/@esbuild/linux-s390x": {
   588	      "version": "0.25.12",
   589	      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
   590	      "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
   591	      "cpu": [
   592	        "s390x"
   593	      ],
   594	      "dev": true,
   595	      "license": "MIT",
   596	      "optional": true,
   597	      "os": [
   598	        "linux"
   599	      ],
   600	      "engines": {
   601	        "node": ">=18"
   602	      }
   603	    },
   604	    "node_modules/@esbuild/linux-x64": {
   605	      "version": "0.25.12",
   606	      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
   607	      "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
   608	      "cpu": [
   609	        "x64"
   610	      ],
   611	      "dev": true,
   612	      "license": "MIT",
   613	      "optional": true,
   614	      "os": [
   615	        "linux"
   616	      ],
   617	      "engines": {
   618	        "node": ">=18"
   619	      }
   620	    },
   621	    "node_modules/@esbuild/netbsd-arm64": {
   622	      "version": "0.25.12",
   623	      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
   624	      "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
   625	      "cpu": [
   626	        "arm64"
   627	      ],
   628	      "dev": true,
   629	      "license": "MIT",
   630	      "optional": true,
   631	      "os": [
   632	        "netbsd"
   633	      ],
   634	      "engines": {
   635	        "node": ">=18"
   636	      }
   637	    },
   638	    "node_modules/@esbuild/netbsd-x64": {
   639	      "version": "0.25.12",
   640	      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
   641	      "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
   642	      "cpu": [
   643	        "x64"
   644	      ],
   645	      "dev": true,
   646	      "license": "MIT",
   647	      "optional": true,
   648	      "os": [
   649	        "netbsd"
   650	      ],
   651	      "engines": {
   652	        "node": ">=18"
   653	      }
   654	    },
   655	    "node_modules/@esbuild/openbsd-arm64": {
   656	      "version": "0.25.12",
   657	      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
   658	      "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
   659	      "cpu": [
   660	        "arm64"
   661	      ],
   662	      "dev": true,
   663	      "license": "MIT",
   664	      "optional": true,
   665	      "os": [
   666	        "openbsd"
   667	      ],
   668	      "engines": {
   669	        "node": ">=18"
   670	      }
   671	    },
   672	    "node_modules/@esbuild/openbsd-x64": {
   673	      "version": "0.25.12",
   674	      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
   675	      "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
   676	      "cpu": [
   677	        "x64"
   678	      ],
   679	      "dev": true,
   680	      "license": "MIT",
   681	      "optional": true,
   682	      "os": [
   683	        "openbsd"
   684	      ],
   685	      "engines": {
   686	        "node": ">=18"
   687	      }
   688	    },
   689	    "node_modules/@esbuild/openharmony-arm64": {
   690	      "version": "0.25.12",
   691	      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
   692	      "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
   693	      "cpu": [
   694	        "arm64"
   695	      ],
   696	      "dev": true,
   697	      "license": "MIT",
   698	      "optional": true,
   699	      "os": [
   700	        "openharmony"
   701	      ],
   702	      "engines": {
   703	        "node": ">=18"
   704	      }
   705	    },
   706	    "node_modules/@esbuild/sunos-x64": {
   707	      "version": "0.25.12",
   708	      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
   709	      "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
   710	      "cpu": [
   711	        "x64"
   712	      ],
   713	      "dev": true,
   714	      "license": "MIT",
   715	      "optional": true,
   716	      "os": [
   717	        "sunos"
   718	      ],
   719	      "engines": {
   720	        "node": ">=18"
   721	      }
   722	    },
   723	    "node_modules/@esbuild/win32-arm64": {
   724	      "version": "0.25.12",
   725	      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
   726	      "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
   727	      "cpu": [
   728	        "arm64"
   729	      ],
   730	      "dev": true,
   731	      "license": "MIT",
   732	      "optional": true,
   733	      "os": [
   734	        "win32"
   735	      ],
   736	      "engines": {
   737	        "node": ">=18"
   738	      }
   739	    },
   740	    "node_modules/@esbuild/win32-ia32": {
   741	      "version": "0.25.12",
   742	      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
   743	      "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
   744	      "cpu": [
   745	        "ia32"
   746	      ],
   747	      "dev": true,
   748	      "license": "MIT",
   749	      "optional": true,
   750	      "os": [
   751	        "win32"
   752	      ],
   753	      "engines": {
   754	        "node": ">=18"
   755	      }
   756	    },
   757	    "node_modules/@esbuild/win32-x64": {
   758	      "version": "0.25.12",
   759	      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
   760	      "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
   761	      "cpu": [
   762	        "x64"
   763	      ],
   764	      "dev": true,
   765	      "license": "MIT",
   766	      "optional": true,
   767	      "os": [
   768	        "win32"
   769	      ],
   770	      "engines": {
   771	        "node": ">=18"
   772	      }
   773	    },
   774	    "node_modules/@jridgewell/gen-mapping": {
   775	      "version": "0.3.13",
   776	      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
   777	      "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
   778	      "dev": true,
   779	      "license": "MIT",
   780	      "dependencies": {
   781	        "@jridgewell/sourcemap-codec": "^1.5.0",
   782	        "@jridgewell/trace-mapping": "^0.3.24"
   783	      }
   784	    },
   785	    "node_modules/@jridgewell/remapping": {
   786	      "version": "2.3.5",
   787	      "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
   788	      "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
   789	      "dev": true,
   790	      "license": "MIT",
   791	      "dependencies": {
   792	        "@jridgewell/gen-mapping": "^0.3.5",
   793	        "@jridgewell/trace-mapping": "^0.3.24"
   794	      }
   795	    },
   796	    "node_modules/@jridgewell/resolve-uri": {
   797	      "version": "3.1.2",
   798	      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
   799	      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
   800	      "dev": true,
   801	      "license": "MIT",
   802	      "engines": {
   803	        "node": ">=6.0.0"
   804	      }
   805	    },
   806	    "node_modules/@jridgewell/sourcemap-codec": {
   807	      "version": "1.5.5",
   808	      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
   809	      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
   810	      "dev": true,
   811	      "license": "MIT"
   812	    },
   813	    "node_modules/@jridgewell/trace-mapping": {
   814	      "version": "0.3.31",
   815	      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
   816	      "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
   817	      "dev": true,
   818	      "license": "MIT",
   819	      "dependencies": {
   820	        "@jridgewell/resolve-uri": "^3.1.0",
   821	        "@jridgewell/sourcemap-codec": "^1.4.14"
   822	      }
   823	    },
   824	    "node_modules/@nodelib/fs.scandir": {
   825	      "version": "2.1.5",
   826	      "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
   827	      "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
   828	      "dev": true,
   829	      "license": "MIT",
   830	      "dependencies": {
   831	        "@nodelib/fs.stat": "2.0.5",
   832	        "run-parallel": "^1.1.9"
   833	      },
   834	      "engines": {
   835	        "node": ">= 8"
   836	      }
   837	    },
   838	    "node_modules/@nodelib/fs.stat": {
   839	      "version": "2.0.5",
   840	      "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
   841	      "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
   842	      "dev": true,
   843	      "license": "MIT",
   844	      "engines": {
   845	        "node": ">= 8"
   846	      }
   847	    },
   848	    "node_modules/@nodelib/fs.walk": {
   849	      "version": "1.2.8",
   850	      "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
   851	      "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
   852	      "dev": true,
   853	      "license": "MIT",
   854	      "dependencies": {
   855	        "@nodelib/fs.scandir": "2.1.5",
   856	        "fastq": "^1.6.0"
   857	      },
   858	      "engines": {
   859	        "node": ">= 8"
   860	      }
   861	    },
   862	    "node_modules/@rolldown/pluginutils": {
   863	      "version": "1.0.0-beta.27",
   864	      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
   865	      "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
   866	      "dev": true,
   867	      "license": "MIT"
   868	    },
   869	    "node_modules/@rollup/rollup-android-arm-eabi": {
   870	      "version": "4.59.0",
   871	      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
   872	      "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
   873	      "cpu": [
   874	        "arm"
   875	      ],
   876	      "dev": true,
   877	      "license": "MIT",
   878	      "optional": true,
   879	      "os": [
   880	        "android"
   881	      ]
   882	    },
   883	    "node_modules/@rollup/rollup-android-arm64": {
   884	      "version": "4.59.0",
   885	      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
   886	      "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
   887	      "cpu": [
   888	        "arm64"
   889	      ],
   890	      "dev": true,
   891	      "license": "MIT",
   892	      "optional": true,
   893	      "os": [
   894	        "android"
   895	      ]
   896	    },
   897	    "node_modules/@rollup/rollup-darwin-arm64": {
   898	      "version": "4.59.0",
   899	      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
   900	      "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
   901	      "cpu": [
   902	        "arm64"
   903	      ],
   904	      "dev": true,
   905	      "license": "MIT",
   906	      "optional": true,
   907	      "os": [
   908	        "darwin"
   909	      ]
   910	    },
   911	    "node_modules/@rollup/rollup-darwin-x64": {
   912	      "version": "4.59.0",
   913	      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
   914	      "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
   915	      "cpu": [
   916	        "x64"
   917	      ],
   918	      "dev": true,
   919	      "license": "MIT",
   920	      "optional": true,
   921	      "os": [
   922	        "darwin"
   923	      ]
   924	    },
   925	    "node_modules/@rollup/rollup-freebsd-arm64": {
   926	      "version": "4.59.0",
   927	      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
   928	      "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
   929	      "cpu": [
   930	        "arm64"
   931	      ],
   932	      "dev": true,
   933	      "license": "MIT",
   934	      "optional": true,
   935	      "os": [
   936	        "freebsd"
   937	      ]
   938	    },
   939	    "node_modules/@rollup/rollup-freebsd-x64": {
   940	      "version": "4.59.0",
   941	      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
   942	      "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
   943	      "cpu": [
   944	        "x64"
   945	      ],
   946	      "dev": true,
   947	      "license": "MIT",
   948	      "optional": true,
   949	      "os": [
   950	        "freebsd"
   951	      ]
   952	    },
   953	    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
   954	      "version": "4.59.0",
   955	      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
   956	      "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
   957	      "cpu": [
   958	        "arm"
   959	      ],
   960	      "dev": true,
   961	      "license": "MIT",
   962	      "optional": true,
   963	      "os": [
   964	        "linux"
   965	      ]
   966	    },
   967	    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
   968	      "version": "4.59.0",
   969	      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
   970	      "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
   971	      "cpu": [
   972	        "arm"
   973	      ],
   974	      "dev": true,
   975	      "license": "MIT",
   976	      "optional": true,
   977	      "os": [
   978	        "linux"
   979	      ]
   980	    },
   981	    "node_modules/@rollup/rollup-linux-arm64-gnu": {
   982	      "version": "4.59.0",
   983	      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
   984	      "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
   985	      "cpu": [
   986	        "arm64"
   987	      ],
   988	      "dev": true,
   989	      "license": "MIT",
   990	      "optional": true,
   991	      "os": [
   992	        "linux"
   993	      ]
   994	    },
   995	    "node_modules/@rollup/rollup-linux-arm64-musl": {
   996	      "version": "4.59.0",
   997	      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
   998	      "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
   999	      "cpu": [
  1000	        "arm64"
  1001	      ],
  1002	      "dev": true,
  1003	      "license": "MIT",
  1004	      "optional": true,
  1005	      "os": [
  1006	        "linux"
  1007	      ]
  1008	    },
  1009	    "node_modules/@rollup/rollup-linux-loong64-gnu": {
  1010	      "version": "4.59.0",
  1011	      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
  1012	      "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
  1013	      "cpu": [
  1014	        "loong64"
  1015	      ],
  1016	      "dev": true,
  1017	      "license": "MIT",
  1018	      "optional": true,
  1019	      "os": [
  1020	        "linux"
  1021	      ]
  1022	    },
  1023	    "node_modules/@rollup/rollup-linux-loong64-musl": {
  1024	      "version": "4.59.0",
  1025	      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
  1026	      "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
  1027	      "cpu": [
  1028	        "loong64"
  1029	      ],
  1030	      "dev": true,
  1031	      "license": "MIT",
  1032	      "optional": true,
  1033	      "os": [
  1034	        "linux"
  1035	      ]
  1036	    },
  1037	    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
  1038	      "version": "4.59.0",
  1039	      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
  1040	      "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
  1041	      "cpu": [
  1042	        "ppc64"
  1043	      ],
  1044	      "dev": true,
  1045	      "license": "MIT",
  1046	      "optional": true,
  1047	      "os": [
  1048	        "linux"
  1049	      ]
  1050	    },
  1051	    "node_modules/@rollup/rollup-linux-ppc64-musl": {
  1052	      "version": "4.59.0",
  1053	      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
  1054	      "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
  1055	      "cpu": [
  1056	        "ppc64"
  1057	      ],
  1058	      "dev": true,
  1059	      "license": "MIT",
  1060	      "optional": true,
  1061	      "os": [
  1062	        "linux"
  1063	      ]
  1064	    },
  1065	    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
  1066	      "version": "4.59.0",
  1067	      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
  1068	      "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
  1069	      "cpu": [
  1070	        "riscv64"
  1071	      ],
  1072	      "dev": true,
  1073	      "license": "MIT",
  1074	      "optional": true,
  1075	      "os": [
  1076	        "linux"
  1077	      ]
  1078	    },
  1079	    "node_modules/@rollup/rollup-linux-riscv64-musl": {
  1080	      "version": "4.59.0",
  1081	      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
  1082	      "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
  1083	      "cpu": [
  1084	        "riscv64"
  1085	      ],
  1086	      "dev": true,
  1087	      "license": "MIT",
  1088	      "optional": true,
  1089	      "os": [
  1090	        "linux"
  1091	      ]
  1092	    },
  1093	    "node_modules/@rollup/rollup-linux-s390x-gnu": {
  1094	      "version": "4.59.0",
  1095	      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
  1096	      "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
  1097	      "cpu": [
  1098	        "s390x"
  1099	      ],
  1100	      "dev": true,
  1101	      "license": "MIT",
  1102	      "optional": true,
  1103	      "os": [
  1104	        "linux"
  1105	      ]
  1106	    },
  1107	    "node_modules/@rollup/rollup-linux-x64-gnu": {
  1108	      "version": "4.59.0",
  1109	      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
  1110	      "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
  1111	      "cpu": [
  1112	        "x64"
  1113	      ],
  1114	      "dev": true,
  1115	      "license": "MIT",
  1116	      "optional": true,
  1117	      "os": [
  1118	        "linux"
  1119	      ]
  1120	    },
  1121	    "node_modules/@rollup/rollup-linux-x64-musl": {
  1122	      "version": "4.59.0",
  1123	      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
  1124	      "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
  1125	      "cpu": [
  1126	        "x64"
  1127	      ],
  1128	      "dev": true,
  1129	      "license": "MIT",
  1130	      "optional": true,
  1131	      "os": [
  1132	        "linux"
  1133	      ]
  1134	    },
  1135	    "node_modules/@rollup/rollup-openbsd-x64": {
  1136	      "version": "4.59.0",
  1137	      "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
  1138	      "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
  1139	      "cpu": [
  1140	        "x64"
  1141	      ],
  1142	      "dev": true,
  1143	      "license": "MIT",
  1144	      "optional": true,
  1145	      "os": [
  1146	        "openbsd"
  1147	      ]
  1148	    },
  1149	    "node_modules/@rollup/rollup-openharmony-arm64": {
  1150	      "version": "4.59.0",
  1151	      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
  1152	      "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
  1153	      "cpu": [
  1154	        "arm64"
  1155	      ],
  1156	      "dev": true,
  1157	      "license": "MIT",
  1158	      "optional": true,
  1159	      "os": [
  1160	        "openharmony"
  1161	      ]
  1162	    },
  1163	    "node_modules/@rollup/rollup-win32-arm64-msvc": {
  1164	      "version": "4.59.0",
  1165	      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
  1166	      "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
  1167	      "cpu": [
  1168	        "arm64"
  1169	      ],
  1170	      "dev": true,
  1171	      "license": "MIT",
  1172	      "optional": true,
  1173	      "os": [
  1174	        "win32"
  1175	      ]
  1176	    },
  1177	    "node_modules/@rollup/rollup-win32-ia32-msvc": {
  1178	      "version": "4.59.0",
  1179	      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
  1180	      "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
  1181	      "cpu": [
  1182	        "ia32"
  1183	      ],
  1184	      "dev": true,
  1185	      "license": "MIT",
  1186	      "optional": true,
  1187	      "os": [
  1188	        "win32"
  1189	      ]
  1190	    },
  1191	    "node_modules/@rollup/rollup-win32-x64-gnu": {
  1192	      "version": "4.59.0",
  1193	      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
  1194	      "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
  1195	      "cpu": [
  1196	        "x64"
  1197	      ],
  1198	      "dev": true,
  1199	      "license": "MIT",
  1200	      "optional": true,
  1201	      "os": [
  1202	        "win32"
  1203	      ]
  1204	    },
  1205	    "node_modules/@rollup/rollup-win32-x64-msvc": {
  1206	      "version": "4.59.0",
  1207	      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
  1208	      "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
  1209	      "cpu": [
  1210	        "x64"
  1211	      ],
  1212	      "dev": true,
  1213	      "license": "MIT",
  1214	      "optional": true,
  1215	      "os": [
  1216	        "win32"
  1217	      ]
  1218	    },
  1219	    "node_modules/@types/babel__core": {
  1220	      "version": "7.20.5",
  1221	      "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
  1222	      "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
  1223	      "dev": true,
  1224	      "license": "MIT",
  1225	      "dependencies": {
  1226	        "@babel/parser": "^7.20.7",
  1227	        "@babel/types": "^7.20.7",
  1228	        "@types/babel__generator": "*",
  1229	        "@types/babel__template": "*",
  1230	        "@types/babel__traverse": "*"
  1231	      }
  1232	    },
  1233	    "node_modules/@types/babel__generator": {
  1234	      "version": "7.27.0",
  1235	      "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
  1236	      "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
  1237	      "dev": true,
  1238	      "license": "MIT",
  1239	      "dependencies": {
  1240	        "@babel/types": "^7.0.0"
  1241	      }
  1242	    },
  1243	    "node_modules/@types/babel__template": {
  1244	      "version": "7.4.4",
  1245	      "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
  1246	      "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
  1247	      "dev": true,
  1248	      "license": "MIT",
  1249	      "dependencies": {
  1250	        "@babel/parser": "^7.1.0",
  1251	        "@babel/types": "^7.0.0"
  1252	      }
  1253	    },
  1254	    "node_modules/@types/babel__traverse": {
  1255	      "version": "7.28.0",
  1256	      "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
  1257	      "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
  1258	      "dev": true,
  1259	      "license": "MIT",
  1260	      "dependencies": {
  1261	        "@babel/types": "^7.28.2"
  1262	      }
  1263	    },
  1264	    "node_modules/@types/debug": {
  1265	      "version": "4.1.13",
  1266	      "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
  1267	      "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
  1268	      "license": "MIT",
  1269	      "dependencies": {
  1270	        "@types/ms": "*"
  1271	      }
  1272	    },
  1273	    "node_modules/@types/estree": {
  1274	      "version": "1.0.8",
  1275	      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
  1276	      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
  1277	      "license": "MIT"
  1278	    },
  1279	    "node_modules/@types/estree-jsx": {
  1280	      "version": "1.0.5",
  1281	      "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
  1282	      "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
  1283	      "license": "MIT",
  1284	      "dependencies": {
  1285	        "@types/estree": "*"
  1286	      }
  1287	    },
  1288	    "node_modules/@types/hast": {
  1289	      "version": "3.0.4",
  1290	      "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
  1291	      "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
  1292	      "license": "MIT",
  1293	      "dependencies": {
  1294	        "@types/unist": "*"
  1295	      }
  1296	    },
  1297	    "node_modules/@types/mdast": {
  1298	      "version": "4.0.4",
  1299	      "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
  1300	      "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
  1301	      "license": "MIT",
  1302	      "dependencies": {
  1303	        "@types/unist": "*"
  1304	      }
  1305	    },
  1306	    "node_modules/@types/ms": {
  1307	      "version": "2.1.0",
  1308	      "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
  1309	      "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
  1310	      "license": "MIT"
  1311	    },
  1312	    "node_modules/@types/prop-types": {
  1313	      "version": "15.7.15",
  1314	      "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
  1315	      "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
  1316	      "license": "MIT"
  1317	    },
  1318	    "node_modules/@types/react": {
  1319	      "version": "18.3.28",
  1320	      "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
  1321	      "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
  1322	      "license": "MIT",
  1323	      "dependencies": {
  1324	        "@types/prop-types": "*",
  1325	        "csstype": "^3.2.2"
  1326	      }
  1327	    },
  1328	    "node_modules/@types/unist": {
  1329	      "version": "3.0.3",
  1330	      "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
  1331	      "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
  1332	      "license": "MIT"
  1333	    },
  1334	    "node_modules/@ungap/structured-clone": {
  1335	      "version": "1.3.0",
  1336	      "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
  1337	      "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
  1338	      "license": "ISC"
  1339	    },
  1340	    "node_modules/@vitejs/plugin-react": {
  1341	      "version": "4.7.0",
  1342	      "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
  1343	      "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
  1344	      "dev": true,
  1345	      "license": "MIT",
  1346	      "dependencies": {
  1347	        "@babel/core": "^7.28.0",
  1348	        "@babel/plugin-transform-react-jsx-self": "^7.27.1",
  1349	        "@babel/plugin-transform-react-jsx-source": "^7.27.1",
  1350	        "@rolldown/pluginutils": "1.0.0-beta.27",
  1351	        "@types/babel__core": "^7.20.5",
  1352	        "react-refresh": "^0.17.0"
  1353	      },
  1354	      "engines": {
  1355	        "node": "^14.18.0 || >=16.0.0"
  1356	      },
  1357	      "peerDependencies": {
  1358	        "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
  1359	      }
  1360	    },
  1361	    "node_modules/any-promise": {
  1362	      "version": "1.3.0",
  1363	      "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
  1364	      "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
  1365	      "dev": true,
  1366	      "license": "MIT"
  1367	    },
  1368	    "node_modules/anymatch": {
  1369	      "version": "3.1.3",
  1370	      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
  1371	      "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
  1372	      "dev": true,
  1373	      "license": "ISC",
  1374	      "dependencies": {
  1375	        "normalize-path": "^3.0.0",
  1376	        "picomatch": "^2.0.4"
  1377	      },
  1378	      "engines": {
  1379	        "node": ">= 8"
  1380	      }
  1381	    },
  1382	    "node_modules/anymatch/node_modules/picomatch": {
  1383	      "version": "2.3.2",
  1384	      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
  1385	      "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
  1386	      "dev": true,
  1387	      "license": "MIT",
  1388	      "engines": {
  1389	        "node": ">=8.6"
  1390	      },
  1391	      "funding": {
  1392	        "url": "https://github.com/sponsors/jonschlinkert"
  1393	      }
  1394	    },
  1395	    "node_modules/arg": {
  1396	      "version": "5.0.2",
  1397	      "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
  1398	      "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
  1399	      "dev": true,
  1400	      "license": "MIT"
  1401	    },
  1402	    "node_modules/autoprefixer": {
  1403	      "version": "10.4.27",
  1404	      "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
  1405	      "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==",
  1406	      "dev": true,
  1407	      "funding": [
  1408	        {
  1409	          "type": "opencollective",
  1410	          "url": "https://opencollective.com/postcss/"
  1411	        },
  1412	        {
  1413	          "type": "tidelift",
  1414	          "url": "https://tidelift.com/funding/github/npm/autoprefixer"
  1415	        },
  1416	        {
  1417	          "type": "github",
  1418	          "url": "https://github.com/sponsors/ai"
  1419	        }
  1420	      ],
  1421	      "license": "MIT",
  1422	      "dependencies": {
  1423	        "browserslist": "^4.28.1",
  1424	        "caniuse-lite": "^1.0.30001774",
  1425	        "fraction.js": "^5.3.4",
  1426	        "picocolors": "^1.1.1",
  1427	        "postcss-value-parser": "^4.2.0"
  1428	      },
  1429	      "bin": {
  1430	        "autoprefixer": "bin/autoprefixer"
  1431	      },
  1432	      "engines": {
  1433	        "node": "^10 || ^12 || >=14"
  1434	      },
  1435	      "peerDependencies": {
  1436	        "postcss": "^8.1.0"
  1437	      }
  1438	    },
  1439	    "node_modules/bail": {
  1440	      "version": "2.0.2",
  1441	      "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
  1442	      "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==",
  1443	      "license": "MIT",
  1444	      "funding": {
  1445	        "type": "github",
  1446	        "url": "https://github.com/sponsors/wooorm"
  1447	      }
  1448	    },
  1449	    "node_modules/baseline-browser-mapping": {
  1450	      "version": "2.10.8",
  1451	      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz",
  1452	      "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==",
  1453	      "dev": true,
  1454	      "license": "Apache-2.0",
  1455	      "bin": {
  1456	        "baseline-browser-mapping": "dist/cli.cjs"
  1457	      },
  1458	      "engines": {
  1459	        "node": ">=6.0.0"
  1460	      }
  1461	    },
  1462	    "node_modules/binary-extensions": {
  1463	      "version": "2.3.0",
  1464	      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
  1465	      "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
  1466	      "dev": true,
  1467	      "license": "MIT",
  1468	      "engines": {
  1469	        "node": ">=8"
  1470	      },
  1471	      "funding": {
  1472	        "url": "https://github.com/sponsors/sindresorhus"
  1473	      }
  1474	    },
  1475	    "node_modules/braces": {
  1476	      "version": "3.0.3",
  1477	      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
  1478	      "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
  1479	      "dev": true,
  1480	      "license": "MIT",
  1481	      "dependencies": {
  1482	        "fill-range": "^7.1.1"
  1483	      },
  1484	      "engines": {
  1485	        "node": ">=8"
  1486	      }
  1487	    },
  1488	    "node_modules/browserslist": {
  1489	      "version": "4.28.1",
  1490	      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
  1491	      "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
  1492	      "dev": true,
  1493	      "funding": [
  1494	        {
  1495	          "type": "opencollective",
  1496	          "url": "https://opencollective.com/browserslist"
  1497	        },
  1498	        {
  1499	          "type": "tidelift",
  1500	          "url": "https://tidelift.com/funding/github/npm/browserslist"
  1501	        },
  1502	        {
  1503	          "type": "github",
  1504	          "url": "https://github.com/sponsors/ai"
  1505	        }
  1506	      ],
  1507	      "license": "MIT",
  1508	      "dependencies": {
  1509	        "baseline-browser-mapping": "^2.9.0",
  1510	        "caniuse-lite": "^1.0.30001759",
  1511	        "electron-to-chromium": "^1.5.263",
  1512	        "node-releases": "^2.0.27",
  1513	        "update-browserslist-db": "^1.2.0"
  1514	      },
  1515	      "bin": {
  1516	        "browserslist": "cli.js"
  1517	      },
  1518	      "engines": {
  1519	        "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
  1520	      }
  1521	    },
  1522	    "node_modules/camelcase-css": {
  1523	      "version": "2.0.1",
  1524	      "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
  1525	      "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
  1526	      "dev": true,
  1527	      "license": "MIT",
  1528	      "engines": {
  1529	        "node": ">= 6"
  1530	      }
  1531	    },
  1532	    "node_modules/caniuse-lite": {
  1533	      "version": "1.0.30001780",
  1534	      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz",
  1535	      "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==",
  1536	      "dev": true,
  1537	      "funding": [
  1538	        {
  1539	          "type": "opencollective",
  1540	          "url": "https://opencollective.com/browserslist"
  1541	        },
  1542	        {
  1543	          "type": "tidelift",
  1544	          "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
  1545	        },
  1546	        {
  1547	          "type": "github",
  1548	          "url": "https://github.com/sponsors/ai"
  1549	        }
  1550	      ],
  1551	      "license": "CC-BY-4.0"
  1552	    },
  1553	    "node_modules/ccount": {
  1554	      "version": "2.0.1",
  1555	      "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
  1556	      "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
  1557	      "license": "MIT",
  1558	      "funding": {
  1559	        "type": "github",
  1560	        "url": "https://github.com/sponsors/wooorm"
  1561	      }
  1562	    },
  1563	    "node_modules/character-entities": {
  1564	      "version": "2.0.2",
  1565	      "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
  1566	      "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
  1567	      "license": "MIT",
  1568	      "funding": {
  1569	        "type": "github",
  1570	        "url": "https://github.com/sponsors/wooorm"
  1571	      }
  1572	    },
  1573	    "node_modules/character-entities-html4": {
  1574	      "version": "2.1.0",
  1575	      "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
  1576	      "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
  1577	      "license": "MIT",
  1578	      "funding": {
  1579	        "type": "github",
  1580	        "url": "https://github.com/sponsors/wooorm"
  1581	      }
  1582	    },
  1583	    "node_modules/character-entities-legacy": {
  1584	      "version": "3.0.0",
  1585	      "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
  1586	      "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
  1587	      "license": "MIT",
  1588	      "funding": {
  1589	        "type": "github",
  1590	        "url": "https://github.com/sponsors/wooorm"
  1591	      }
  1592	    },
  1593	    "node_modules/character-reference-invalid": {
  1594	      "version": "2.0.1",
  1595	      "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
  1596	      "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
  1597	      "license": "MIT",
  1598	      "funding": {
  1599	        "type": "github",
  1600	        "url": "https://github.com/sponsors/wooorm"
  1601	      }
  1602	    },
  1603	    "node_modules/chokidar": {
  1604	      "version": "3.6.0",
  1605	      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
  1606	      "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
  1607	      "dev": true,
  1608	      "license": "MIT",
  1609	      "dependencies": {
  1610	        "anymatch": "~3.1.2",
  1611	        "braces": "~3.0.2",
  1612	        "glob-parent": "~5.1.2",
  1613	        "is-binary-path": "~2.1.0",
  1614	        "is-glob": "~4.0.1",
  1615	        "normalize-path": "~3.0.0",
  1616	        "readdirp": "~3.6.0"
  1617	      },
  1618	      "engines": {
  1619	        "node": ">= 8.10.0"
  1620	      },
  1621	      "funding": {
  1622	        "url": "https://paulmillr.com/funding/"
  1623	      },
  1624	      "optionalDependencies": {
  1625	        "fsevents": "~2.3.2"
  1626	      }
  1627	    },
  1628	    "node_modules/chokidar/node_modules/glob-parent": {
  1629	      "version": "5.1.2",
  1630	      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
  1631	      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
  1632	      "dev": true,
  1633	      "license": "ISC",
  1634	      "dependencies": {
  1635	        "is-glob": "^4.0.1"
  1636	      },
  1637	      "engines": {
  1638	        "node": ">= 6"
  1639	      }
  1640	    },
  1641	    "node_modules/comma-separated-tokens": {
  1642	      "version": "2.0.3",
  1643	      "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
  1644	      "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
  1645	      "license": "MIT",
  1646	      "funding": {
  1647	        "type": "github",
  1648	        "url": "https://github.com/sponsors/wooorm"
  1649	      }
  1650	    },
  1651	    "node_modules/commander": {
  1652	      "version": "4.1.1",
  1653	      "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
  1654	      "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
  1655	      "dev": true,
  1656	      "license": "MIT",
  1657	      "engines": {
  1658	        "node": ">= 6"
  1659	      }
  1660	    },
  1661	    "node_modules/convert-source-map": {
  1662	      "version": "2.0.0",
  1663	      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
  1664	      "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
  1665	      "dev": true,
  1666	      "license": "MIT"
  1667	    },
  1668	    "node_modules/cookie": {
  1669	      "version": "1.1.1",
  1670	      "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
  1671	      "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
  1672	      "license": "MIT",
  1673	      "engines": {
  1674	        "node": ">=18"
  1675	      },
  1676	      "funding": {
  1677	        "type": "opencollective",
  1678	        "url": "https://opencollective.com/express"
  1679	      }
  1680	    },
  1681	    "node_modules/cssesc": {
  1682	      "version": "3.0.0",
  1683	      "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
  1684	      "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
  1685	      "dev": true,
  1686	      "license": "MIT",
  1687	      "bin": {
  1688	        "cssesc": "bin/cssesc"
  1689	      },
  1690	      "engines": {
  1691	        "node": ">=4"
  1692	      }
  1693	    },
  1694	    "node_modules/csstype": {
  1695	      "version": "3.2.3",
  1696	      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
  1697	      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
  1698	      "license": "MIT"
  1699	    },
  1700	    "node_modules/debug": {
  1701	      "version": "4.4.3",
  1702	      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
  1703	      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
  1704	      "license": "MIT",
  1705	      "dependencies": {
  1706	        "ms": "^2.1.3"
  1707	      },
  1708	      "engines": {
  1709	        "node": ">=6.0"
  1710	      },
  1711	      "peerDependenciesMeta": {
  1712	        "supports-color": {
  1713	          "optional": true
  1714	        }
  1715	      }
  1716	    },
  1717	    "node_modules/decode-named-character-reference": {
  1718	      "version": "1.3.0",
  1719	      "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
  1720	      "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
  1721	      "license": "MIT",
  1722	      "dependencies": {
  1723	        "character-entities": "^2.0.0"
  1724	      },
  1725	      "funding": {
  1726	        "type": "github",
  1727	        "url": "https://github.com/sponsors/wooorm"
  1728	      }
  1729	    },
  1730	    "node_modules/dequal": {
  1731	      "version": "2.0.3",
  1732	      "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
  1733	      "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
  1734	      "license": "MIT",
  1735	      "engines": {
  1736	        "node": ">=6"
  1737	      }
  1738	    },
  1739	    "node_modules/devlop": {
  1740	      "version": "1.1.0",
  1741	      "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
  1742	      "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
  1743	      "license": "MIT",
  1744	      "dependencies": {
  1745	        "dequal": "^2.0.0"
  1746	      },
  1747	      "funding": {
  1748	        "type": "github",
  1749	        "url": "https://github.com/sponsors/wooorm"
  1750	      }
  1751	    },
  1752	    "node_modules/didyoumean": {
  1753	      "version": "1.2.2",
  1754	      "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
  1755	      "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
  1756	      "dev": true,
  1757	      "license": "Apache-2.0"
  1758	    },
  1759	    "node_modules/dlv": {
  1760	      "version": "1.1.3",
  1761	      "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
  1762	      "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
  1763	      "dev": true,
  1764	      "license": "MIT"
  1765	    },
  1766	    "node_modules/electron-to-chromium": {
  1767	      "version": "1.5.321",
  1768	      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
  1769	      "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==",
  1770	      "dev": true,
  1771	      "license": "ISC"
  1772	    },
  1773	    "node_modules/esbuild": {
  1774	      "version": "0.25.12",
  1775	      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
  1776	      "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
  1777	      "dev": true,
  1778	      "hasInstallScript": true,
  1779	      "license": "MIT",
  1780	      "bin": {
  1781	        "esbuild": "bin/esbuild"
  1782	      },
  1783	      "engines": {
  1784	        "node": ">=18"
  1785	      },
  1786	      "optionalDependencies": {
  1787	        "@esbuild/aix-ppc64": "0.25.12",
  1788	        "@esbuild/android-arm": "0.25.12",
  1789	        "@esbuild/android-arm64": "0.25.12",
  1790	        "@esbuild/android-x64": "0.25.12",
  1791	        "@esbuild/darwin-arm64": "0.25.12",
  1792	        "@esbuild/darwin-x64": "0.25.12",
  1793	        "@esbuild/freebsd-arm64": "0.25.12",
  1794	        "@esbuild/freebsd-x64": "0.25.12",
  1795	        "@esbuild/linux-arm": "0.25.12",
  1796	        "@esbuild/linux-arm64": "0.25.12",
  1797	        "@esbuild/linux-ia32": "0.25.12",
  1798	        "@esbuild/linux-loong64": "0.25.12",
  1799	        "@esbuild/linux-mips64el": "0.25.12",
  1800	        "@esbuild/linux-ppc64": "0.25.12",
  1801	        "@esbuild/linux-riscv64": "0.25.12",
  1802	        "@esbuild/linux-s390x": "0.25.12",
  1803	        "@esbuild/linux-x64": "0.25.12",
  1804	        "@esbuild/netbsd-arm64": "0.25.12",
  1805	        "@esbuild/netbsd-x64": "0.25.12",
  1806	        "@esbuild/openbsd-arm64": "0.25.12",
  1807	        "@esbuild/openbsd-x64": "0.25.12",
  1808	        "@esbuild/openharmony-arm64": "0.25.12",
  1809	        "@esbuild/sunos-x64": "0.25.12",
  1810	        "@esbuild/win32-arm64": "0.25.12",
  1811	        "@esbuild/win32-ia32": "0.25.12",
  1812	        "@esbuild/win32-x64": "0.25.12"
  1813	      }
  1814	    },
  1815	    "node_modules/escalade": {
  1816	      "version": "3.2.0",
  1817	      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
  1818	      "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
  1819	      "dev": true,
  1820	      "license": "MIT",
  1821	      "engines": {
  1822	        "node": ">=6"
  1823	      }
  1824	    },
  1825	    "node_modules/escape-string-regexp": {
  1826	      "version": "5.0.0",
  1827	      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
  1828	      "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
  1829	      "license": "MIT",
  1830	      "engines": {
  1831	        "node": ">=12"
  1832	      },
  1833	      "funding": {
  1834	        "url": "https://github.com/sponsors/sindresorhus"
  1835	      }
  1836	    },
  1837	    "node_modules/estree-util-is-identifier-name": {
  1838	      "version": "3.0.0",
  1839	      "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
  1840	      "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
  1841	      "license": "MIT",
  1842	      "funding": {
  1843	        "type": "opencollective",
  1844	        "url": "https://opencollective.com/unified"
  1845	      }
  1846	    },
  1847	    "node_modules/extend": {
  1848	      "version": "3.0.2",
  1849	      "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
  1850	      "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
  1851	      "license": "MIT"
  1852	    },
  1853	    "node_modules/fast-glob": {
  1854	      "version": "3.3.3",
  1855	      "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
  1856	      "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
  1857	      "dev": true,
  1858	      "license": "MIT",
  1859	      "dependencies": {
  1860	        "@nodelib/fs.stat": "^2.0.2",
  1861	        "@nodelib/fs.walk": "^1.2.3",
  1862	        "glob-parent": "^5.1.2",
  1863	        "merge2": "^1.3.0",
  1864	        "micromatch": "^4.0.8"
  1865	      },
  1866	      "engines": {
  1867	        "node": ">=8.6.0"
  1868	      }
  1869	    },
  1870	    "node_modules/fast-glob/node_modules/glob-parent": {
  1871	      "version": "5.1.2",
  1872	      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
  1873	      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
  1874	      "dev": true,
  1875	      "license": "ISC",
  1876	      "dependencies": {
  1877	        "is-glob": "^4.0.1"
  1878	      },
  1879	      "engines": {
  1880	        "node": ">= 6"
  1881	      }
  1882	    },
  1883	    "node_modules/fastq": {
  1884	      "version": "1.20.1",
  1885	      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
  1886	      "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
  1887	      "dev": true,
  1888	      "license": "ISC",
  1889	      "dependencies": {
  1890	        "reusify": "^1.0.4"
  1891	      }
  1892	    },
  1893	    "node_modules/fault": {
  1894	      "version": "1.0.4",
  1895	      "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz",
  1896	      "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==",
  1897	      "license": "MIT",
  1898	      "dependencies": {
  1899	        "format": "^0.2.0"
  1900	      },
  1901	      "funding": {
  1902	        "type": "github",
  1903	        "url": "https://github.com/sponsors/wooorm"
  1904	      }
  1905	    },
  1906	    "node_modules/fdir": {
  1907	      "version": "6.5.0",
  1908	      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
  1909	      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
  1910	      "dev": true,
  1911	      "license": "MIT",
  1912	      "engines": {
  1913	        "node": ">=12.0.0"
  1914	      },
  1915	      "peerDependencies": {
  1916	        "picomatch": "^3 || ^4"
  1917	      },
  1918	      "peerDependenciesMeta": {
  1919	        "picomatch": {
  1920	          "optional": true
  1921	        }
  1922	      }
  1923	    },
  1924	    "node_modules/fill-range": {
  1925	      "version": "7.1.1",
  1926	      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
  1927	      "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
  1928	      "dev": true,
  1929	      "license": "MIT",
  1930	      "dependencies": {
  1931	        "to-regex-range": "^5.0.1"
  1932	      },
  1933	      "engines": {
  1934	        "node": ">=8"
  1935	      }
  1936	    },
  1937	    "node_modules/format": {
  1938	      "version": "0.2.2",
  1939	      "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
  1940	      "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==",
  1941	      "engines": {
  1942	        "node": ">=0.4.x"
  1943	      }
  1944	    },
  1945	    "node_modules/fraction.js": {
  1946	      "version": "5.3.4",
  1947	      "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
  1948	      "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
  1949	      "dev": true,
  1950	      "license": "MIT",
  1951	      "engines": {
  1952	        "node": "*"
  1953	      },
  1954	      "funding": {
  1955	        "type": "github",
  1956	        "url": "https://github.com/sponsors/rawify"
  1957	      }
  1958	    },
  1959	    "node_modules/fsevents": {
  1960	      "version": "2.3.3",
  1961	      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
  1962	      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
  1963	      "dev": true,
  1964	      "hasInstallScript": true,
  1965	      "license": "MIT",
  1966	      "optional": true,
  1967	      "os": [
  1968	        "darwin"
  1969	      ],
  1970	      "engines": {
  1971	        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
  1972	      }
  1973	    },
  1974	    "node_modules/function-bind": {
  1975	      "version": "1.1.2",
  1976	      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
  1977	      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
  1978	      "dev": true,
  1979	      "license": "MIT",
  1980	      "funding": {
  1981	        "url": "https://github.com/sponsors/ljharb"
  1982	      }
  1983	    },
  1984	    "node_modules/gensync": {
  1985	      "version": "1.0.0-beta.2",
  1986	      "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
  1987	      "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
  1988	      "dev": true,
  1989	      "license": "MIT",
  1990	      "engines": {
  1991	        "node": ">=6.9.0"
  1992	      }
  1993	    },
  1994	    "node_modules/glob-parent": {
  1995	      "version": "6.0.2",
  1996	      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
  1997	      "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
  1998	      "dev": true,
  1999	      "license": "ISC",
  2000	      "dependencies": {
  2001	        "is-glob": "^4.0.3"
  2002	      },
  2003	      "engines": {
  2004	        "node": ">=10.13.0"
  2005	      }
  2006	    },
  2007	    "node_modules/hasown": {
  2008	      "version": "2.0.2",
  2009	      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
  2010	      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
  2011	      "dev": true,
  2012	      "license": "MIT",
  2013	      "dependencies": {
  2014	        "function-bind": "^1.1.2"
  2015	      },
  2016	      "engines": {
  2017	        "node": ">= 0.4"
  2018	      }
  2019	    },
  2020	    "node_modules/hast-util-parse-selector": {
  2021	      "version": "2.2.5",
  2022	      "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz",
  2023	      "integrity": "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==",
  2024	      "license": "MIT",
  2025	      "funding": {
  2026	        "type": "opencollective",
  2027	        "url": "https://opencollective.com/unified"
  2028	      }
  2029	    },
  2030	    "node_modules/hast-util-to-jsx-runtime": {
  2031	      "version": "2.3.6",
  2032	      "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
  2033	      "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
  2034	      "license": "MIT",
  2035	      "dependencies": {
  2036	        "@types/estree": "^1.0.0",
  2037	        "@types/hast": "^3.0.0",
  2038	        "@types/unist": "^3.0.0",
  2039	        "comma-separated-tokens": "^2.0.0",
  2040	        "devlop": "^1.0.0",
  2041	        "estree-util-is-identifier-name": "^3.0.0",
  2042	        "hast-util-whitespace": "^3.0.0",
  2043	        "mdast-util-mdx-expression": "^2.0.0",
  2044	        "mdast-util-mdx-jsx": "^3.0.0",
  2045	        "mdast-util-mdxjs-esm": "^2.0.0",
  2046	        "property-information": "^7.0.0",
  2047	        "space-separated-tokens": "^2.0.0",
  2048	        "style-to-js": "^1.0.0",
  2049	        "unist-util-position": "^5.0.0",
  2050	        "vfile-message": "^4.0.0"
  2051	      },
  2052	      "funding": {
  2053	        "type": "opencollective",
  2054	        "url": "https://opencollective.com/unified"
  2055	      }
  2056	    },
  2057	    "node_modules/hast-util-whitespace": {
  2058	      "version": "3.0.0",
  2059	      "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
  2060	      "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
  2061	      "license": "MIT",
  2062	      "dependencies": {
  2063	        "@types/hast": "^3.0.0"
  2064	      },
  2065	      "funding": {
  2066	        "type": "opencollective",
  2067	        "url": "https://opencollective.com/unified"
  2068	      }
  2069	    },
  2070	    "node_modules/hastscript": {
  2071	      "version": "6.0.0",
  2072	      "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz",
  2073	      "integrity": "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==",
  2074	      "license": "MIT",
  2075	      "dependencies": {
  2076	        "@types/hast": "^2.0.0",
  2077	        "comma-separated-tokens": "^1.0.0",
  2078	        "hast-util-parse-selector": "^2.0.0",
  2079	        "property-information": "^5.0.0",
  2080	        "space-separated-tokens": "^1.0.0"
  2081	      },
  2082	      "funding": {
  2083	        "type": "opencollective",
  2084	        "url": "https://opencollective.com/unified"
  2085	      }
  2086	    },
  2087	    "node_modules/hastscript/node_modules/@types/hast": {
  2088	      "version": "2.3.10",
  2089	      "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz",
  2090	      "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==",
  2091	      "license": "MIT",
  2092	      "dependencies": {
  2093	        "@types/unist": "^2"
  2094	      }
  2095	    },
  2096	    "node_modules/hastscript/node_modules/@types/unist": {
  2097	      "version": "2.0.11",
  2098	      "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
  2099	      "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
  2100	      "license": "MIT"
  2101	    },
  2102	    "node_modules/hastscript/node_modules/comma-separated-tokens": {
  2103	      "version": "1.0.8",
  2104	      "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz",
  2105	      "integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==",
  2106	      "license": "MIT",
  2107	      "funding": {
  2108	        "type": "github",
  2109	        "url": "https://github.com/sponsors/wooorm"
  2110	      }
  2111	    },
  2112	    "node_modules/hastscript/node_modules/property-information": {
  2113	      "version": "5.6.0",
  2114	      "resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz",
  2115	      "integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==",
  2116	      "license": "MIT",
  2117	      "dependencies": {
  2118	        "xtend": "^4.0.0"
  2119	      },
  2120	      "funding": {
  2121	        "type": "github",
  2122	        "url": "https://github.com/sponsors/wooorm"
  2123	      }
  2124	    },
  2125	    "node_modules/hastscript/node_modules/space-separated-tokens": {
  2126	      "version": "1.1.5",
  2127	      "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz",
  2128	      "integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==",
  2129	      "license": "MIT",
  2130	      "funding": {
  2131	        "type": "github",
  2132	        "url": "https://github.com/sponsors/wooorm"
  2133	      }
  2134	    },
  2135	    "node_modules/highlight.js": {
  2136	      "version": "10.7.3",
  2137	      "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
  2138	      "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==",
  2139	      "license": "BSD-3-Clause",
  2140	      "engines": {
  2141	        "node": "*"
  2142	      }
  2143	    },
  2144	    "node_modules/highlightjs-vue": {
  2145	      "version": "1.0.0",
  2146	      "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz",
  2147	      "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
  2148	      "license": "CC0-1.0"
  2149	    },
  2150	    "node_modules/html-url-attributes": {
  2151	      "version": "3.0.1",
  2152	      "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
  2153	      "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
  2154	      "license": "MIT",
  2155	      "funding": {
  2156	        "type": "opencollective",
  2157	        "url": "https://opencollective.com/unified"
  2158	      }
  2159	    },
  2160	    "node_modules/inline-style-parser": {
  2161	      "version": "0.2.7",
  2162	      "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
  2163	      "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
  2164	      "license": "MIT"
  2165	    },
  2166	    "node_modules/is-alphabetical": {
  2167	      "version": "2.0.1",
  2168	      "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
  2169	      "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
  2170	      "license": "MIT",
  2171	      "funding": {
  2172	        "type": "github",
  2173	        "url": "https://github.com/sponsors/wooorm"
  2174	      }
  2175	    },
  2176	    "node_modules/is-alphanumerical": {
  2177	      "version": "2.0.1",
  2178	      "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
  2179	      "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
  2180	      "license": "MIT",
  2181	      "dependencies": {
  2182	        "is-alphabetical": "^2.0.0",
  2183	        "is-decimal": "^2.0.0"
  2184	      },
  2185	      "funding": {
  2186	        "type": "github",
  2187	        "url": "https://github.com/sponsors/wooorm"
  2188	      }
  2189	    },
  2190	    "node_modules/is-binary-path": {
  2191	      "version": "2.1.0",
  2192	      "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
  2193	      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
  2194	      "dev": true,
  2195	      "license": "MIT",
  2196	      "dependencies": {
  2197	        "binary-extensions": "^2.0.0"
  2198	      },
  2199	      "engines": {
  2200	        "node": ">=8"
  2201	      }
  2202	    },
  2203	    "node_modules/is-core-module": {
  2204	      "version": "2.16.1",
  2205	      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
  2206	      "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
  2207	      "dev": true,
  2208	      "license": "MIT",
  2209	      "dependencies": {
  2210	        "hasown": "^2.0.2"
  2211	      },
  2212	      "engines": {
  2213	        "node": ">= 0.4"
  2214	      },
  2215	      "funding": {
  2216	        "url": "https://github.com/sponsors/ljharb"
  2217	      }
  2218	    },
  2219	    "node_modules/is-decimal": {
  2220	      "version": "2.0.1",
  2221	      "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
  2222	      "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
  2223	      "license": "MIT",
  2224	      "funding": {
  2225	        "type": "github",
  2226	        "url": "https://github.com/sponsors/wooorm"
  2227	      }
  2228	    },
  2229	    "node_modules/is-extglob": {
  2230	      "version": "2.1.1",
  2231	      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
  2232	      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
  2233	      "dev": true,
  2234	      "license": "MIT",
  2235	      "engines": {
  2236	        "node": ">=0.10.0"
  2237	      }
  2238	    },
  2239	    "node_modules/is-glob": {
  2240	      "version": "4.0.3",
  2241	      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
  2242	      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
  2243	      "dev": true,
  2244	      "license": "MIT",
  2245	      "dependencies": {
  2246	        "is-extglob": "^2.1.1"
  2247	      },
  2248	      "engines": {
  2249	        "node": ">=0.10.0"
  2250	      }
  2251	    },
  2252	    "node_modules/is-hexadecimal": {
  2253	      "version": "2.0.1",
  2254	      "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
  2255	      "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
  2256	      "license": "MIT",
  2257	      "funding": {
  2258	        "type": "github",
  2259	        "url": "https://github.com/sponsors/wooorm"
  2260	      }
  2261	    },
  2262	    "node_modules/is-number": {
  2263	      "version": "7.0.0",
  2264	      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
  2265	      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
  2266	      "dev": true,
  2267	      "license": "MIT",
  2268	      "engines": {
  2269	        "node": ">=0.12.0"
  2270	      }
  2271	    },
  2272	    "node_modules/is-plain-obj": {
  2273	      "version": "4.1.0",
  2274	      "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
  2275	      "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
  2276	      "license": "MIT",
  2277	      "engines": {
  2278	        "node": ">=12"
  2279	      },
  2280	      "funding": {
  2281	        "url": "https://github.com/sponsors/sindresorhus"
  2282	      }
  2283	    },
  2284	    "node_modules/jiti": {
  2285	      "version": "1.21.7",
  2286	      "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
  2287	      "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
  2288	      "dev": true,
  2289	      "license": "MIT",
  2290	      "bin": {
  2291	        "jiti": "bin/jiti.js"
  2292	      }
  2293	    },
  2294	    "node_modules/js-tokens": {
  2295	      "version": "4.0.0",
  2296	      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
  2297	      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
  2298	      "license": "MIT"
  2299	    },
  2300	    "node_modules/jsesc": {
  2301	      "version": "3.1.0",
  2302	      "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
  2303	      "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
  2304	      "dev": true,
  2305	      "license": "MIT",
  2306	      "bin": {
  2307	        "jsesc": "bin/jsesc"
  2308	      },
  2309	      "engines": {
  2310	        "node": ">=6"
  2311	      }
  2312	    },
  2313	    "node_modules/json5": {
  2314	      "version": "2.2.3",
  2315	      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
  2316	      "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
  2317	      "dev": true,
  2318	      "license": "MIT",
  2319	      "bin": {
  2320	        "json5": "lib/cli.js"
  2321	      },
  2322	      "engines": {
  2323	        "node": ">=6"
  2324	      }
  2325	    },
  2326	    "node_modules/lilconfig": {
  2327	      "version": "3.1.3",
  2328	      "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
  2329	      "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
  2330	      "dev": true,
  2331	      "license": "MIT",
  2332	      "engines": {
  2333	        "node": ">=14"
  2334	      },
  2335	      "funding": {
  2336	        "url": "https://github.com/sponsors/antonk52"
  2337	      }
  2338	    },
  2339	    "node_modules/lines-and-columns": {
  2340	      "version": "1.2.4",
  2341	      "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
  2342	      "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
  2343	      "dev": true,
  2344	      "license": "MIT"
  2345	    },
  2346	    "node_modules/longest-streak": {
  2347	      "version": "3.1.0",
  2348	      "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
  2349	      "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
  2350	      "license": "MIT",
  2351	      "funding": {
  2352	        "type": "github",
  2353	        "url": "https://github.com/sponsors/wooorm"
  2354	      }
  2355	    },
  2356	    "node_modules/loose-envify": {
  2357	      "version": "1.4.0",
  2358	      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
  2359	      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
  2360	      "license": "MIT",
  2361	      "dependencies": {
  2362	        "js-tokens": "^3.0.0 || ^4.0.0"
  2363	      },
  2364	      "bin": {
  2365	        "loose-envify": "cli.js"
  2366	      }
  2367	    },
  2368	    "node_modules/lowlight": {
  2369	      "version": "1.20.0",
  2370	      "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz",
  2371	      "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==",
  2372	      "license": "MIT",
  2373	      "dependencies": {
  2374	        "fault": "^1.0.0",
  2375	        "highlight.js": "~10.7.0"
  2376	      },
  2377	      "funding": {
  2378	        "type": "github",
  2379	        "url": "https://github.com/sponsors/wooorm"
  2380	      }
  2381	    },
  2382	    "node_modules/lru-cache": {
  2383	      "version": "5.1.1",
  2384	      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
  2385	      "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
  2386	      "dev": true,
  2387	      "license": "ISC",
  2388	      "dependencies": {
  2389	        "yallist": "^3.0.2"
  2390	      }
  2391	    },
  2392	    "node_modules/lucide-react": {
  2393	      "version": "0.469.0",
  2394	      "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.469.0.tgz",
  2395	      "integrity": "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==",
  2396	      "license": "ISC",
  2397	      "peerDependencies": {
  2398	        "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
  2399	      }
  2400	    },
  2401	    "node_modules/markdown-table": {
  2402	      "version": "3.0.4",
  2403	      "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
  2404	      "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
  2405	      "license": "MIT",
  2406	      "funding": {
  2407	        "type": "github",
  2408	        "url": "https://github.com/sponsors/wooorm"
  2409	      }
  2410	    },
  2411	    "node_modules/mdast-util-find-and-replace": {
  2412	      "version": "3.0.2",
  2413	      "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
  2414	      "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
  2415	      "license": "MIT",
  2416	      "dependencies": {
  2417	        "@types/mdast": "^4.0.0",
  2418	        "escape-string-regexp": "^5.0.0",
  2419	        "unist-util-is": "^6.0.0",
  2420	        "unist-util-visit-parents": "^6.0.0"
  2421	      },
  2422	      "funding": {
  2423	        "type": "opencollective",
  2424	        "url": "https://opencollective.com/unified"
  2425	      }
  2426	    },
  2427	    "node_modules/mdast-util-from-markdown": {
  2428	      "version": "2.0.3",
  2429	      "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
  2430	      "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==",
  2431	      "license": "MIT",
  2432	      "dependencies": {
  2433	        "@types/mdast": "^4.0.0",
  2434	        "@types/unist": "^3.0.0",
  2435	        "decode-named-character-reference": "^1.0.0",
  2436	        "devlop": "^1.0.0",
  2437	        "mdast-util-to-string": "^4.0.0",
  2438	        "micromark": "^4.0.0",
  2439	        "micromark-util-decode-numeric-character-reference": "^2.0.0",
  2440	        "micromark-util-decode-string": "^2.0.0",
  2441	        "micromark-util-normalize-identifier": "^2.0.0",
  2442	        "micromark-util-symbol": "^2.0.0",
  2443	        "micromark-util-types": "^2.0.0",
  2444	        "unist-util-stringify-position": "^4.0.0"
  2445	      },
  2446	      "funding": {
  2447	        "type": "opencollective",
  2448	        "url": "https://opencollective.com/unified"
  2449	      }
  2450	    },
  2451	    "node_modules/mdast-util-gfm": {
  2452	      "version": "3.1.0",
  2453	      "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
  2454	      "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
  2455	      "license": "MIT",
  2456	      "dependencies": {
  2457	        "mdast-util-from-markdown": "^2.0.0",
  2458	        "mdast-util-gfm-autolink-literal": "^2.0.0",
  2459	        "mdast-util-gfm-footnote": "^2.0.0",
  2460	        "mdast-util-gfm-strikethrough": "^2.0.0",
  2461	        "mdast-util-gfm-table": "^2.0.0",
  2462	        "mdast-util-gfm-task-list-item": "^2.0.0",
  2463	        "mdast-util-to-markdown": "^2.0.0"
  2464	      },
  2465	      "funding": {
  2466	        "type": "opencollective",
  2467	        "url": "https://opencollective.com/unified"
  2468	      }
  2469	    },
  2470	    "node_modules/mdast-util-gfm-autolink-literal": {
  2471	      "version": "2.0.1",
  2472	      "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
  2473	      "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
  2474	      "license": "MIT",
  2475	      "dependencies": {
  2476	        "@types/mdast": "^4.0.0",
  2477	        "ccount": "^2.0.0",
  2478	        "devlop": "^1.0.0",
  2479	        "mdast-util-find-and-replace": "^3.0.0",
  2480	        "micromark-util-character": "^2.0.0"
  2481	      },
  2482	      "funding": {
  2483	        "type": "opencollective",
  2484	        "url": "https://opencollective.com/unified"
  2485	      }
  2486	    },
  2487	    "node_modules/mdast-util-gfm-footnote": {
  2488	      "version": "2.1.0",
  2489	      "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
  2490	      "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
  2491	      "license": "MIT",
  2492	      "dependencies": {
  2493	        "@types/mdast": "^4.0.0",
  2494	        "devlop": "^1.1.0",
  2495	        "mdast-util-from-markdown": "^2.0.0",
  2496	        "mdast-util-to-markdown": "^2.0.0",
  2497	        "micromark-util-normalize-identifier": "^2.0.0"
  2498	      },
  2499	      "funding": {
  2500	        "type": "opencollective",
  2501	        "url": "https://opencollective.com/unified"
  2502	      }
  2503	    },
  2504	    "node_modules/mdast-util-gfm-strikethrough": {
  2505	      "version": "2.0.0",
  2506	      "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
  2507	      "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
  2508	      "license": "MIT",
  2509	      "dependencies": {
  2510	        "@types/mdast": "^4.0.0",
  2511	        "mdast-util-from-markdown": "^2.0.0",
  2512	        "mdast-util-to-markdown": "^2.0.0"
  2513	      },
  2514	      "funding": {
  2515	        "type": "opencollective",
  2516	        "url": "https://opencollective.com/unified"
  2517	      }
  2518	    },
  2519	    "node_modules/mdast-util-gfm-table": {
  2520	      "version": "2.0.0",
  2521	      "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
  2522	      "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
  2523	      "license": "MIT",
  2524	      "dependencies": {
  2525	        "@types/mdast": "^4.0.0",
  2526	        "devlop": "^1.0.0",
  2527	        "markdown-table": "^3.0.0",
  2528	        "mdast-util-from-markdown": "^2.0.0",
  2529	        "mdast-util-to-markdown": "^2.0.0"
  2530	      },
  2531	      "funding": {
  2532	        "type": "opencollective",
  2533	        "url": "https://opencollective.com/unified"
  2534	      }
  2535	    },
  2536	    "node_modules/mdast-util-gfm-task-list-item": {
  2537	      "version": "2.0.0",
  2538	      "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
  2539	      "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
  2540	      "license": "MIT",
  2541	      "dependencies": {
  2542	        "@types/mdast": "^4.0.0",
  2543	        "devlop": "^1.0.0",
  2544	        "mdast-util-from-markdown": "^2.0.0",
  2545	        "mdast-util-to-markdown": "^2.0.0"
  2546	      },
  2547	      "funding": {
  2548	        "type": "opencollective",
  2549	        "url": "https://opencollective.com/unified"
  2550	      }
  2551	    },
  2552	    "node_modules/mdast-util-mdx-expression": {
  2553	      "version": "2.0.1",
  2554	      "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
  2555	      "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
  2556	      "license": "MIT",
  2557	      "dependencies": {
  2558	        "@types/estree-jsx": "^1.0.0",
  2559	        "@types/hast": "^3.0.0",
  2560	        "@types/mdast": "^4.0.0",
  2561	        "devlop": "^1.0.0",
  2562	        "mdast-util-from-markdown": "^2.0.0",
  2563	        "mdast-util-to-markdown": "^2.0.0"
  2564	      },
  2565	      "funding": {
  2566	        "type": "opencollective",
  2567	        "url": "https://opencollective.com/unified"
  2568	      }
  2569	    },
  2570	    "node_modules/mdast-util-mdx-jsx": {
  2571	      "version": "3.2.0",
  2572	      "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
  2573	      "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
  2574	      "license": "MIT",
  2575	      "dependencies": {
  2576	        "@types/estree-jsx": "^1.0.0",
  2577	        "@types/hast": "^3.0.0",
  2578	        "@types/mdast": "^4.0.0",
  2579	        "@types/unist": "^3.0.0",
  2580	        "ccount": "^2.0.0",
  2581	        "devlop": "^1.1.0",
  2582	        "mdast-util-from-markdown": "^2.0.0",
  2583	        "mdast-util-to-markdown": "^2.0.0",
  2584	        "parse-entities": "^4.0.0",
  2585	        "stringify-entities": "^4.0.0",
  2586	        "unist-util-stringify-position": "^4.0.0",
  2587	        "vfile-message": "^4.0.0"
  2588	      },
  2589	      "funding": {
  2590	        "type": "opencollective",
  2591	        "url": "https://opencollective.com/unified"
  2592	      }
  2593	    },
  2594	    "node_modules/mdast-util-mdxjs-esm": {
  2595	      "version": "2.0.1",
  2596	      "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
  2597	      "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
  2598	      "license": "MIT",
  2599	      "dependencies": {
  2600	        "@types/estree-jsx": "^1.0.0",
  2601	        "@types/hast": "^3.0.0",
  2602	        "@types/mdast": "^4.0.0",
  2603	        "devlop": "^1.0.0",
  2604	        "mdast-util-from-markdown": "^2.0.0",
  2605	        "mdast-util-to-markdown": "^2.0.0"
  2606	      },
  2607	      "funding": {
  2608	        "type": "opencollective",
  2609	        "url": "https://opencollective.com/unified"
  2610	      }
  2611	    },
  2612	    "node_modules/mdast-util-phrasing": {
  2613	      "version": "4.1.0",
  2614	      "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
  2615	      "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
  2616	      "license": "MIT",
  2617	      "dependencies": {
  2618	        "@types/mdast": "^4.0.0",
  2619	        "unist-util-is": "^6.0.0"
  2620	      },
  2621	      "funding": {
  2622	        "type": "opencollective",
  2623	        "url": "https://opencollective.com/unified"
  2624	      }
  2625	    },
  2626	    "node_modules/mdast-util-to-hast": {
  2627	      "version": "13.2.1",
  2628	      "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
  2629	      "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
  2630	      "license": "MIT",
  2631	      "dependencies": {
  2632	        "@types/hast": "^3.0.0",
  2633	        "@types/mdast": "^4.0.0",
  2634	        "@ungap/structured-clone": "^1.0.0",
  2635	        "devlop": "^1.0.0",
  2636	        "micromark-util-sanitize-uri": "^2.0.0",
  2637	        "trim-lines": "^3.0.0",
  2638	        "unist-util-position": "^5.0.0",
  2639	        "unist-util-visit": "^5.0.0",
  2640	        "vfile": "^6.0.0"
  2641	      },
  2642	      "funding": {
  2643	        "type": "opencollective",
  2644	        "url": "https://opencollective.com/unified"
  2645	      }
  2646	    },
  2647	    "node_modules/mdast-util-to-markdown": {
  2648	      "version": "2.1.2",
  2649	      "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz",
  2650	      "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==",
  2651	      "license": "MIT",
  2652	      "dependencies": {
  2653	        "@types/mdast": "^4.0.0",
  2654	        "@types/unist": "^3.0.0",
  2655	        "longest-streak": "^3.0.0",
  2656	        "mdast-util-phrasing": "^4.0.0",
  2657	        "mdast-util-to-string": "^4.0.0",
  2658	        "micromark-util-classify-character": "^2.0.0",
  2659	        "micromark-util-decode-string": "^2.0.0",
  2660	        "unist-util-visit": "^5.0.0",
  2661	        "zwitch": "^2.0.0"
  2662	      },
  2663	      "funding": {
  2664	        "type": "opencollective",
  2665	        "url": "https://opencollective.com/unified"
  2666	      }
  2667	    },
  2668	    "node_modules/mdast-util-to-string": {
  2669	      "version": "4.0.0",
  2670	      "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
  2671	      "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
  2672	      "license": "MIT",
  2673	      "dependencies": {
  2674	        "@types/mdast": "^4.0.0"
  2675	      },
  2676	      "funding": {
  2677	        "type": "opencollective",
  2678	        "url": "https://opencollective.com/unified"
  2679	      }
  2680	    },
  2681	    "node_modules/merge2": {
  2682	      "version": "1.4.1",
  2683	      "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
  2684	      "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
  2685	      "dev": true,
  2686	      "license": "MIT",
  2687	      "engines": {
  2688	        "node": ">= 8"
  2689	      }
  2690	    },
  2691	    "node_modules/micromark": {
  2692	      "version": "4.0.2",
  2693	      "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
  2694	      "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==",
  2695	      "funding": [
  2696	        {
  2697	          "type": "GitHub Sponsors",
  2698	          "url": "https://github.com/sponsors/unifiedjs"
  2699	        },
  2700	        {
  2701	          "type": "OpenCollective",
  2702	          "url": "https://opencollective.com/unified"
  2703	        }
  2704	      ],
  2705	      "license": "MIT",
  2706	      "dependencies": {
  2707	        "@types/debug": "^4.0.0",
  2708	        "debug": "^4.0.0",
  2709	        "decode-named-character-reference": "^1.0.0",
  2710	        "devlop": "^1.0.0",
  2711	        "micromark-core-commonmark": "^2.0.0",
  2712	        "micromark-factory-space": "^2.0.0",
  2713	        "micromark-util-character": "^2.0.0",
  2714	        "micromark-util-chunked": "^2.0.0",
  2715	        "micromark-util-combine-extensions": "^2.0.0",
  2716	        "micromark-util-decode-numeric-character-reference": "^2.0.0",
  2717	        "micromark-util-encode": "^2.0.0",
  2718	        "micromark-util-normalize-identifier": "^2.0.0",
  2719	        "micromark-util-resolve-all": "^2.0.0",
  2720	        "micromark-util-sanitize-uri": "^2.0.0",
  2721	        "micromark-util-subtokenize": "^2.0.0",
  2722	        "micromark-util-symbol": "^2.0.0",
  2723	        "micromark-util-types": "^2.0.0"
  2724	      }
  2725	    },
  2726	    "node_modules/micromark-core-commonmark": {
  2727	      "version": "2.0.3",
  2728	      "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz",
  2729	      "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==",
  2730	      "funding": [
  2731	        {
  2732	          "type": "GitHub Sponsors",
  2733	          "url": "https://github.com/sponsors/unifiedjs"
  2734	        },
  2735	        {
  2736	          "type": "OpenCollective",
  2737	          "url": "https://opencollective.com/unified"
  2738	        }
  2739	      ],
  2740	      "license": "MIT",
  2741	      "dependencies": {
  2742	        "decode-named-character-reference": "^1.0.0",
  2743	        "devlop": "^1.0.0",
  2744	        "micromark-factory-destination": "^2.0.0",
  2745	        "micromark-factory-label": "^2.0.0",
  2746	        "micromark-factory-space": "^2.0.0",
  2747	        "micromark-factory-title": "^2.0.0",
  2748	        "micromark-factory-whitespace": "^2.0.0",
  2749	        "micromark-util-character": "^2.0.0",
  2750	        "micromark-util-chunked": "^2.0.0",
  2751	        "micromark-util-classify-character": "^2.0.0",
  2752	        "micromark-util-html-tag-name": "^2.0.0",
  2753	        "micromark-util-normalize-identifier": "^2.0.0",
  2754	        "micromark-util-resolve-all": "^2.0.0",
  2755	        "micromark-util-subtokenize": "^2.0.0",
  2756	        "micromark-util-symbol": "^2.0.0",
  2757	        "micromark-util-types": "^2.0.0"
  2758	      }
  2759	    },
  2760	    "node_modules/micromark-extension-gfm": {
  2761	      "version": "3.0.0",
  2762	      "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
  2763	      "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
  2764	      "license": "MIT",
  2765	      "dependencies": {
  2766	        "micromark-extension-gfm-autolink-literal": "^2.0.0",
  2767	        "micromark-extension-gfm-footnote": "^2.0.0",
  2768	        "micromark-extension-gfm-strikethrough": "^2.0.0",
  2769	        "micromark-extension-gfm-table": "^2.0.0",
  2770	        "micromark-extension-gfm-tagfilter": "^2.0.0",
  2771	        "micromark-extension-gfm-task-list-item": "^2.0.0",
  2772	        "micromark-util-combine-extensions": "^2.0.0",
  2773	        "micromark-util-types": "^2.0.0"
  2774	      },
  2775	      "funding": {
  2776	        "type": "opencollective",
  2777	        "url": "https://opencollective.com/unified"
  2778	      }
  2779	    },
  2780	    "node_modules/micromark-extension-gfm-autolink-literal": {
  2781	      "version": "2.1.0",
  2782	      "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
  2783	      "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
  2784	      "license": "MIT",
  2785	      "dependencies": {
  2786	        "micromark-util-character": "^2.0.0",
  2787	        "micromark-util-sanitize-uri": "^2.0.0",
  2788	        "micromark-util-symbol": "^2.0.0",
  2789	        "micromark-util-types": "^2.0.0"
  2790	      },
  2791	      "funding": {
  2792	        "type": "opencollective",
  2793	        "url": "https://opencollective.com/unified"
  2794	      }
  2795	    },
  2796	    "node_modules/micromark-extension-gfm-footnote": {
  2797	      "version": "2.1.0",
  2798	      "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
  2799	      "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
  2800	      "license": "MIT",
  2801	      "dependencies": {
  2802	        "devlop": "^1.0.0",
  2803	        "micromark-core-commonmark": "^2.0.0",
  2804	        "micromark-factory-space": "^2.0.0",
  2805	        "micromark-util-character": "^2.0.0",
  2806	        "micromark-util-normalize-identifier": "^2.0.0",
  2807	        "micromark-util-sanitize-uri": "^2.0.0",
  2808	        "micromark-util-symbol": "^2.0.0",
  2809	        "micromark-util-types": "^2.0.0"
  2810	      },
  2811	      "funding": {
  2812	        "type": "opencollective",
  2813	        "url": "https://opencollective.com/unified"
  2814	      }
  2815	    },
  2816	    "node_modules/micromark-extension-gfm-strikethrough": {
  2817	      "version": "2.1.0",
  2818	      "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
  2819	      "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
  2820	      "license": "MIT",
  2821	      "dependencies": {
  2822	        "devlop": "^1.0.0",
  2823	        "micromark-util-chunked": "^2.0.0",
  2824	        "micromark-util-classify-character": "^2.0.0",
  2825	        "micromark-util-resolve-all": "^2.0.0",
  2826	        "micromark-util-symbol": "^2.0.0",
  2827	        "micromark-util-types": "^2.0.0"
  2828	      },
  2829	      "funding": {
  2830	        "type": "opencollective",
  2831	        "url": "https://opencollective.com/unified"
  2832	      }
  2833	    },
  2834	    "node_modules/micromark-extension-gfm-table": {
  2835	      "version": "2.1.1",
  2836	      "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
  2837	      "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
  2838	      "license": "MIT",
  2839	      "dependencies": {
  2840	        "devlop": "^1.0.0",
  2841	        "micromark-factory-space": "^2.0.0",
  2842	        "micromark-util-character": "^2.0.0",
  2843	        "micromark-util-symbol": "^2.0.0",
  2844	        "micromark-util-types": "^2.0.0"
  2845	      },
  2846	      "funding": {
  2847	        "type": "opencollective",
  2848	        "url": "https://opencollective.com/unified"
  2849	      }
  2850	    },
  2851	    "node_modules/micromark-extension-gfm-tagfilter": {
  2852	      "version": "2.0.0",
  2853	      "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
  2854	      "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
  2855	      "license": "MIT",
  2856	      "dependencies": {
  2857	        "micromark-util-types": "^2.0.0"
  2858	      },
  2859	      "funding": {
  2860	        "type": "opencollective",
  2861	        "url": "https://opencollective.com/unified"
  2862	      }
  2863	    },
  2864	    "node_modules/micromark-extension-gfm-task-list-item": {
  2865	      "version": "2.1.0",
  2866	      "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
  2867	      "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
  2868	      "license": "MIT",
  2869	      "dependencies": {
  2870	        "devlop": "^1.0.0",
  2871	        "micromark-factory-space": "^2.0.0",
  2872	        "micromark-util-character": "^2.0.0",
  2873	        "micromark-util-symbol": "^2.0.0",
  2874	        "micromark-util-types": "^2.0.0"
  2875	      },
  2876	      "funding": {
  2877	        "type": "opencollective",
  2878	        "url": "https://opencollective.com/unified"
  2879	      }
  2880	    },
  2881	    "node_modules/micromark-factory-destination": {
  2882	      "version": "2.0.1",
  2883	      "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
  2884	      "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==",
  2885	      "funding": [
  2886	        {
  2887	          "type": "GitHub Sponsors",
  2888	          "url": "https://github.com/sponsors/unifiedjs"
  2889	        },
  2890	        {
  2891	          "type": "OpenCollective",
  2892	          "url": "https://opencollective.com/unified"
  2893	        }
  2894	      ],
  2895	      "license": "MIT",
  2896	      "dependencies": {
  2897	        "micromark-util-character": "^2.0.0",
  2898	        "micromark-util-symbol": "^2.0.0",
  2899	        "micromark-util-types": "^2.0.0"
  2900	      }
  2901	    },
  2902	    "node_modules/micromark-factory-label": {
  2903	      "version": "2.0.1",
  2904	      "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz",
  2905	      "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==",
  2906	      "funding": [
  2907	        {
  2908	          "type": "GitHub Sponsors",
  2909	          "url": "https://github.com/sponsors/unifiedjs"
  2910	        },
  2911	        {
  2912	          "type": "OpenCollective",
  2913	          "url": "https://opencollective.com/unified"
  2914	        }
  2915	      ],
  2916	      "license": "MIT",
  2917	      "dependencies": {
  2918	        "devlop": "^1.0.0",
  2919	        "micromark-util-character": "^2.0.0",
  2920	        "micromark-util-symbol": "^2.0.0",
  2921	        "micromark-util-types": "^2.0.0"
  2922	      }
  2923	    },
  2924	    "node_modules/micromark-factory-space": {
  2925	      "version": "2.0.1",
  2926	      "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz",
  2927	      "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==",
  2928	      "funding": [
  2929	        {
  2930	          "type": "GitHub Sponsors",
  2931	          "url": "https://github.com/sponsors/unifiedjs"
  2932	        },
  2933	        {
  2934	          "type": "OpenCollective",
  2935	          "url": "https://opencollective.com/unified"
  2936	        }
  2937	      ],
  2938	      "license": "MIT",
  2939	      "dependencies": {
  2940	        "micromark-util-character": "^2.0.0",
  2941	        "micromark-util-types": "^2.0.0"
  2942	      }
  2943	    },
  2944	    "node_modules/micromark-factory-title": {
  2945	      "version": "2.0.1",
  2946	      "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz",
  2947	      "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==",
  2948	      "funding": [
  2949	        {
  2950	          "type": "GitHub Sponsors",
  2951	          "url": "https://github.com/sponsors/unifiedjs"
  2952	        },
  2953	        {
  2954	          "type": "OpenCollective",
  2955	          "url": "https://opencollective.com/unified"
  2956	        }
  2957	      ],
  2958	      "license": "MIT",
  2959	      "dependencies": {
  2960	        "micromark-factory-space": "^2.0.0",
  2961	        "micromark-util-character": "^2.0.0",
  2962	        "micromark-util-symbol": "^2.0.0",
  2963	        "micromark-util-types": "^2.0.0"
  2964	      }
  2965	    },
  2966	    "node_modules/micromark-factory-whitespace": {
  2967	      "version": "2.0.1",
  2968	      "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz",
  2969	      "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==",
  2970	      "funding": [
  2971	        {
  2972	          "type": "GitHub Sponsors",
  2973	          "url": "https://github.com/sponsors/unifiedjs"
  2974	        },
  2975	        {
  2976	          "type": "OpenCollective",
  2977	          "url": "https://opencollective.com/unified"
  2978	        }
  2979	      ],
  2980	      "license": "MIT",
  2981	      "dependencies": {
  2982	        "micromark-factory-space": "^2.0.0",
  2983	        "micromark-util-character": "^2.0.0",
  2984	        "micromark-util-symbol": "^2.0.0",
  2985	        "micromark-util-types": "^2.0.0"
  2986	      }
  2987	    },
  2988	    "node_modules/micromark-util-character": {
  2989	      "version": "2.1.1",
  2990	      "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
  2991	      "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
  2992	      "funding": [
  2993	        {
  2994	          "type": "GitHub Sponsors",
  2995	          "url": "https://github.com/sponsors/unifiedjs"
  2996	        },
  2997	        {
  2998	          "type": "OpenCollective",
  2999	          "url": "https://opencollective.com/unified"
  3000	        }
  3001	      ],
  3002	      "license": "MIT",
  3003	      "dependencies": {
  3004	        "micromark-util-symbol": "^2.0.0",
  3005	        "micromark-util-types": "^2.0.0"
  3006	      }
  3007	    },
  3008	    "node_modules/micromark-util-chunked": {
  3009	      "version": "2.0.1",
  3010	      "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz",
  3011	      "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==",
  3012	      "funding": [
  3013	        {
  3014	          "type": "GitHub Sponsors",
  3015	          "url": "https://github.com/sponsors/unifiedjs"
  3016	        },
  3017	        {
  3018	          "type": "OpenCollective",
  3019	          "url": "https://opencollective.com/unified"
  3020	        }
  3021	      ],
  3022	      "license": "MIT",
  3023	      "dependencies": {
  3024	        "micromark-util-symbol": "^2.0.0"
  3025	      }
  3026	    },
  3027	    "node_modules/micromark-util-classify-character": {
  3028	      "version": "2.0.1",
  3029	      "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz",
  3030	      "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==",
  3031	      "funding": [
  3032	        {
  3033	          "type": "GitHub Sponsors",
  3034	          "url": "https://github.com/sponsors/unifiedjs"
  3035	        },
  3036	        {
  3037	          "type": "OpenCollective",
  3038	          "url": "https://opencollective.com/unified"
  3039	        }
  3040	      ],
  3041	      "license": "MIT",
  3042	      "dependencies": {
  3043	        "micromark-util-character": "^2.0.0",
  3044	        "micromark-util-symbol": "^2.0.0",
  3045	        "micromark-util-types": "^2.0.0"
  3046	      }
  3047	    },
  3048	    "node_modules/micromark-util-combine-extensions": {
  3049	      "version": "2.0.1",
  3050	      "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz",
  3051	      "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==",
  3052	      "funding": [
  3053	        {
  3054	          "type": "GitHub Sponsors",
  3055	          "url": "https://github.com/sponsors/unifiedjs"
  3056	        },
  3057	        {
  3058	          "type": "OpenCollective",
  3059	          "url": "https://opencollective.com/unified"
  3060	        }
  3061	      ],
  3062	      "license": "MIT",
  3063	      "dependencies": {
  3064	        "micromark-util-chunked": "^2.0.0",
  3065	        "micromark-util-types": "^2.0.0"
  3066	      }
  3067	    },
  3068	    "node_modules/micromark-util-decode-numeric-character-reference": {
  3069	      "version": "2.0.2",
  3070	      "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz",
  3071	      "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==",
  3072	      "funding": [
  3073	        {
  3074	          "type": "GitHub Sponsors",
  3075	          "url": "https://github.com/sponsors/unifiedjs"
  3076	        },
  3077	        {
  3078	          "type": "OpenCollective",
  3079	          "url": "https://opencollective.com/unified"
  3080	        }
  3081	      ],
  3082	      "license": "MIT",
  3083	      "dependencies": {
  3084	        "micromark-util-symbol": "^2.0.0"
  3085	      }
  3086	    },
  3087	    "node_modules/micromark-util-decode-string": {
  3088	      "version": "2.0.1",
  3089	      "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz",
  3090	      "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==",
  3091	      "funding": [
  3092	        {
  3093	          "type": "GitHub Sponsors",
  3094	          "url": "https://github.com/sponsors/unifiedjs"
  3095	        },
  3096	        {
  3097	          "type": "OpenCollective",
  3098	          "url": "https://opencollective.com/unified"
  3099	        }
  3100	      ],
  3101	      "license": "MIT",
  3102	      "dependencies": {
  3103	        "decode-named-character-reference": "^1.0.0",
  3104	        "micromark-util-character": "^2.0.0",
  3105	        "micromark-util-decode-numeric-character-reference": "^2.0.0",
  3106	        "micromark-util-symbol": "^2.0.0"
  3107	      }
  3108	    },
  3109	    "node_modules/micromark-util-encode": {
  3110	      "version": "2.0.1",
  3111	      "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
  3112	      "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
  3113	      "funding": [
  3114	        {
  3115	          "type": "GitHub Sponsors",
  3116	          "url": "https://github.com/sponsors/unifiedjs"
  3117	        },
  3118	        {
  3119	          "type": "OpenCollective",
  3120	          "url": "https://opencollective.com/unified"
  3121	        }
  3122	      ],
  3123	      "license": "MIT"
  3124	    },
  3125	    "node_modules/micromark-util-html-tag-name": {
  3126	      "version": "2.0.1",
  3127	      "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz",
  3128	      "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==",
  3129	      "funding": [
  3130	        {
  3131	          "type": "GitHub Sponsors",
  3132	          "url": "https://github.com/sponsors/unifiedjs"
  3133	        },
  3134	        {
  3135	          "type": "OpenCollective",
  3136	          "url": "https://opencollective.com/unified"
  3137	        }
  3138	      ],
  3139	      "license": "MIT"
  3140	    },
  3141	    "node_modules/micromark-util-normalize-identifier": {
  3142	      "version": "2.0.1",
  3143	      "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz",
  3144	      "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==",
  3145	      "funding": [
  3146	        {
  3147	          "type": "GitHub Sponsors",
  3148	          "url": "https://github.com/sponsors/unifiedjs"
  3149	        },
  3150	        {
  3151	          "type": "OpenCollective",
  3152	          "url": "https://opencollective.com/unified"
  3153	        }
  3154	      ],
  3155	      "license": "MIT",
  3156	      "dependencies": {
  3157	        "micromark-util-symbol": "^2.0.0"
  3158	      }
  3159	    },
  3160	    "node_modules/micromark-util-resolve-all": {
  3161	      "version": "2.0.1",
  3162	      "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz",
  3163	      "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==",
  3164	      "funding": [
  3165	        {
  3166	          "type": "GitHub Sponsors",
  3167	          "url": "https://github.com/sponsors/unifiedjs"
  3168	        },
  3169	        {
  3170	          "type": "OpenCollective",
  3171	          "url": "https://opencollective.com/unified"
  3172	        }
  3173	      ],
  3174	      "license": "MIT",
  3175	      "dependencies": {
  3176	        "micromark-util-types": "^2.0.0"
  3177	      }
  3178	    },
  3179	    "node_modules/micromark-util-sanitize-uri": {
  3180	      "version": "2.0.1",
  3181	      "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
  3182	      "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
  3183	      "funding": [
  3184	        {
  3185	          "type": "GitHub Sponsors",
  3186	          "url": "https://github.com/sponsors/unifiedjs"
  3187	        },
  3188	        {
  3189	          "type": "OpenCollective",
  3190	          "url": "https://opencollective.com/unified"
  3191	        }
  3192	      ],
  3193	      "license": "MIT",
  3194	      "dependencies": {
  3195	        "micromark-util-character": "^2.0.0",
  3196	        "micromark-util-encode": "^2.0.0",
  3197	        "micromark-util-symbol": "^2.0.0"
  3198	      }
  3199	    },
  3200	    "node_modules/micromark-util-subtokenize": {
  3201	      "version": "2.1.0",
  3202	      "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz",
  3203	      "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==",
  3204	      "funding": [
  3205	        {
  3206	          "type": "GitHub Sponsors",
  3207	          "url": "https://github.com/sponsors/unifiedjs"
  3208	        },
  3209	        {
  3210	          "type": "OpenCollective",
  3211	          "url": "https://opencollective.com/unified"
  3212	        }
  3213	      ],
  3214	      "license": "MIT",
  3215	      "dependencies": {
  3216	        "devlop": "^1.0.0",
  3217	        "micromark-util-chunked": "^2.0.0",
  3218	        "micromark-util-symbol": "^2.0.0",
  3219	        "micromark-util-types": "^2.0.0"
  3220	      }
  3221	    },
  3222	    "node_modules/micromark-util-symbol": {
  3223	      "version": "2.0.1",
  3224	      "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
  3225	      "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
  3226	      "funding": [
  3227	        {
  3228	          "type": "GitHub Sponsors",
  3229	          "url": "https://github.com/sponsors/unifiedjs"
  3230	        },
  3231	        {
  3232	          "type": "OpenCollective",
  3233	          "url": "https://opencollective.com/unified"
  3234	        }
  3235	      ],
  3236	      "license": "MIT"
  3237	    },
  3238	    "node_modules/micromark-util-types": {
  3239	      "version": "2.0.2",
  3240	      "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
  3241	      "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
  3242	      "funding": [
  3243	        {
  3244	          "type": "GitHub Sponsors",
  3245	          "url": "https://github.com/sponsors/unifiedjs"
  3246	        },
  3247	        {
  3248	          "type": "OpenCollective",
  3249	          "url": "https://opencollective.com/unified"
  3250	        }
  3251	      ],
  3252	      "license": "MIT"
  3253	    },
  3254	    "node_modules/micromatch": {
  3255	      "version": "4.0.8",
  3256	      "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
  3257	      "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
  3258	      "dev": true,
  3259	      "license": "MIT",
  3260	      "dependencies": {
  3261	        "braces": "^3.0.3",
  3262	        "picomatch": "^2.3.1"
  3263	      },
  3264	      "engines": {
  3265	        "node": ">=8.6"
  3266	      }
  3267	    },
  3268	    "node_modules/micromatch/node_modules/picomatch": {
  3269	      "version": "2.3.2",
  3270	      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
  3271	      "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
  3272	      "dev": true,
  3273	      "license": "MIT",
  3274	      "engines": {
  3275	        "node": ">=8.6"
  3276	      },
  3277	      "funding": {
  3278	        "url": "https://github.com/sponsors/jonschlinkert"
  3279	      }
  3280	    },
  3281	    "node_modules/ms": {
  3282	      "version": "2.1.3",
  3283	      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
  3284	      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
  3285	      "license": "MIT"
  3286	    },
  3287	    "node_modules/mz": {
  3288	      "version": "2.7.0",
  3289	      "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
  3290	      "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
  3291	      "dev": true,
  3292	      "license": "MIT",
  3293	      "dependencies": {
  3294	        "any-promise": "^1.0.0",
  3295	        "object-assign": "^4.0.1",
  3296	        "thenify-all": "^1.0.0"
  3297	      }
  3298	    },
  3299	    "node_modules/nanoid": {
  3300	      "version": "3.3.11",
  3301	      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
  3302	      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
  3303	      "dev": true,
  3304	      "funding": [
  3305	        {
  3306	          "type": "github",
  3307	          "url": "https://github.com/sponsors/ai"
  3308	        }
  3309	      ],
  3310	      "license": "MIT",
  3311	      "bin": {
  3312	        "nanoid": "bin/nanoid.cjs"
  3313	      },
  3314	      "engines": {
  3315	        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
  3316	      }
  3317	    },
  3318	    "node_modules/node-releases": {
  3319	      "version": "2.0.36",
  3320	      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
  3321	      "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
  3322	      "dev": true,
  3323	      "license": "MIT"
  3324	    },
  3325	    "node_modules/normalize-path": {
  3326	      "version": "3.0.0",
  3327	      "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
  3328	      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
  3329	      "dev": true,
  3330	      "license": "MIT",
  3331	      "engines": {
  3332	        "node": ">=0.10.0"
  3333	      }
  3334	    },
  3335	    "node_modules/object-assign": {
  3336	      "version": "4.1.1",
  3337	      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
  3338	      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
  3339	      "dev": true,
  3340	      "license": "MIT",
  3341	      "engines": {
  3342	        "node": ">=0.10.0"
  3343	      }
  3344	    },
  3345	    "node_modules/object-hash": {
  3346	      "version": "3.0.0",
  3347	      "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
  3348	      "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
  3349	      "dev": true,
  3350	      "license": "MIT",
  3351	      "engines": {
  3352	        "node": ">= 6"
  3353	      }
  3354	    },
  3355	    "node_modules/parse-entities": {
  3356	      "version": "4.0.2",
  3357	      "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
  3358	      "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
  3359	      "license": "MIT",
  3360	      "dependencies": {
  3361	        "@types/unist": "^2.0.0",
  3362	        "character-entities-legacy": "^3.0.0",
  3363	        "character-reference-invalid": "^2.0.0",
  3364	        "decode-named-character-reference": "^1.0.0",
  3365	        "is-alphanumerical": "^2.0.0",
  3366	        "is-decimal": "^2.0.0",
  3367	        "is-hexadecimal": "^2.0.0"
  3368	      },
  3369	      "funding": {
  3370	        "type": "github",
  3371	        "url": "https://github.com/sponsors/wooorm"
  3372	      }
  3373	    },
  3374	    "node_modules/parse-entities/node_modules/@types/unist": {
  3375	      "version": "2.0.11",
  3376	      "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
  3377	      "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
  3378	      "license": "MIT"
  3379	    },
  3380	    "node_modules/path-parse": {
  3381	      "version": "1.0.7",
  3382	      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
  3383	      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
  3384	      "dev": true,
  3385	      "license": "MIT"
  3386	    },
  3387	    "node_modules/picocolors": {
  3388	      "version": "1.1.1",
  3389	      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
  3390	      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
  3391	      "dev": true,
  3392	      "license": "ISC"
  3393	    },
  3394	    "node_modules/picomatch": {
  3395	      "version": "4.0.4",
  3396	      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
  3397	      "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
  3398	      "dev": true,
  3399	      "license": "MIT",
  3400	      "engines": {
  3401	        "node": ">=12"
  3402	      },
  3403	      "funding": {
  3404	        "url": "https://github.com/sponsors/jonschlinkert"
  3405	      }
  3406	    },
  3407	    "node_modules/pify": {
  3408	      "version": "2.3.0",
  3409	      "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
  3410	      "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
  3411	      "dev": true,
  3412	      "license": "MIT",
  3413	      "engines": {
  3414	        "node": ">=0.10.0"
  3415	      }
  3416	    },
  3417	    "node_modules/pirates": {
  3418	      "version": "4.0.7",
  3419	      "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
  3420	      "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
  3421	      "dev": true,
  3422	      "license": "MIT",
  3423	      "engines": {
  3424	        "node": ">= 6"
  3425	      }
  3426	    },
  3427	    "node_modules/postcss": {
  3428	      "version": "8.5.8",
  3429	      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
  3430	      "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
  3431	      "dev": true,
  3432	      "funding": [
  3433	        {
  3434	          "type": "opencollective",
  3435	          "url": "https://opencollective.com/postcss/"
  3436	        },
  3437	        {
  3438	          "type": "tidelift",
  3439	          "url": "https://tidelift.com/funding/github/npm/postcss"
  3440	        },
  3441	        {
  3442	          "type": "github",
  3443	          "url": "https://github.com/sponsors/ai"
  3444	        }
  3445	      ],
  3446	      "license": "MIT",
  3447	      "dependencies": {
  3448	        "nanoid": "^3.3.11",
  3449	        "picocolors": "^1.1.1",
  3450	        "source-map-js": "^1.2.1"
  3451	      },
  3452	      "engines": {
  3453	        "node": "^10 || ^12 || >=14"
  3454	      }
  3455	    },
  3456	    "node_modules/postcss-import": {
  3457	      "version": "15.1.0",
  3458	      "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
  3459	      "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
  3460	      "dev": true,
  3461	      "license": "MIT",
  3462	      "dependencies": {
  3463	        "postcss-value-parser": "^4.0.0",
  3464	        "read-cache": "^1.0.0",
  3465	        "resolve": "^1.1.7"
  3466	      },
  3467	      "engines": {
  3468	        "node": ">=14.0.0"
  3469	      },
  3470	      "peerDependencies": {
  3471	        "postcss": "^8.0.0"
  3472	      }
  3473	    },
  3474	    "node_modules/postcss-js": {
  3475	      "version": "4.1.0",
  3476	      "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
  3477	      "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
  3478	      "dev": true,
  3479	      "funding": [
  3480	        {
  3481	          "type": "opencollective",
  3482	          "url": "https://opencollective.com/postcss/"
  3483	        },
  3484	        {
  3485	          "type": "github",
  3486	          "url": "https://github.com/sponsors/ai"
  3487	        }
  3488	      ],
  3489	      "license": "MIT",
  3490	      "dependencies": {
  3491	        "camelcase-css": "^2.0.1"
  3492	      },
  3493	      "engines": {
  3494	        "node": "^12 || ^14 || >= 16"
  3495	      },
  3496	      "peerDependencies": {
  3497	        "postcss": "^8.4.21"
  3498	      }
  3499	    },
  3500	    "node_modules/postcss-load-config": {
  3501	      "version": "6.0.1",
  3502	      "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
  3503	      "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
  3504	      "dev": true,
  3505	      "funding": [
  3506	        {
  3507	          "type": "opencollective",
  3508	          "url": "https://opencollective.com/postcss/"
  3509	        },
  3510	        {
  3511	          "type": "github",
  3512	          "url": "https://github.com/sponsors/ai"
  3513	        }
  3514	      ],
  3515	      "license": "MIT",
  3516	      "dependencies": {
  3517	        "lilconfig": "^3.1.1"
  3518	      },
  3519	      "engines": {
  3520	        "node": ">= 18"
  3521	      },
  3522	      "peerDependencies": {
  3523	        "jiti": ">=1.21.0",
  3524	        "postcss": ">=8.0.9",
  3525	        "tsx": "^4.8.1",
  3526	        "yaml": "^2.4.2"
  3527	      },
  3528	      "peerDependenciesMeta": {
  3529	        "jiti": {
  3530	          "optional": true
  3531	        },
  3532	        "postcss": {
  3533	          "optional": true
  3534	        },
  3535	        "tsx": {
  3536	          "optional": true
  3537	        },
  3538	        "yaml": {
  3539	          "optional": true
  3540	        }
  3541	      }
  3542	    },
  3543	    "node_modules/postcss-nested": {
  3544	      "version": "6.2.0",
  3545	      "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
  3546	      "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
  3547	      "dev": true,
  3548	      "funding": [
  3549	        {
  3550	          "type": "opencollective",
  3551	          "url": "https://opencollective.com/postcss/"
  3552	        },
  3553	        {
  3554	          "type": "github",
  3555	          "url": "https://github.com/sponsors/ai"
  3556	        }
  3557	      ],
  3558	      "license": "MIT",
  3559	      "dependencies": {
  3560	        "postcss-selector-parser": "^6.1.1"
  3561	      },
  3562	      "engines": {
  3563	        "node": ">=12.0"
  3564	      },
  3565	      "peerDependencies": {
  3566	        "postcss": "^8.2.14"
  3567	      }
  3568	    },
  3569	    "node_modules/postcss-selector-parser": {
  3570	      "version": "6.1.2",
  3571	      "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
  3572	      "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
  3573	      "dev": true,
  3574	      "license": "MIT",
  3575	      "dependencies": {
  3576	        "cssesc": "^3.0.0",
  3577	        "util-deprecate": "^1.0.2"
  3578	      },
  3579	      "engines": {
  3580	        "node": ">=4"
  3581	      }
  3582	    },
  3583	    "node_modules/postcss-value-parser": {
  3584	      "version": "4.2.0",
  3585	      "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
  3586	      "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
  3587	      "dev": true,
  3588	      "license": "MIT"
  3589	    },
  3590	    "node_modules/prismjs": {
  3591	      "version": "1.30.0",
  3592	      "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
  3593	      "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
  3594	      "license": "MIT",
  3595	      "engines": {
  3596	        "node": ">=6"
  3597	      }
  3598	    },
  3599	    "node_modules/property-information": {
  3600	      "version": "7.1.0",
  3601	      "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
  3602	      "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==",
  3603	      "license": "MIT",
  3604	      "funding": {
  3605	        "type": "github",
  3606	        "url": "https://github.com/sponsors/wooorm"
  3607	      }
  3608	    },
  3609	    "node_modules/queue-microtask": {
  3610	      "version": "1.2.3",
  3611	      "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
  3612	      "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
  3613	      "dev": true,
  3614	      "funding": [
  3615	        {
  3616	          "type": "github",
  3617	          "url": "https://github.com/sponsors/feross"
  3618	        },
  3619	        {
  3620	          "type": "patreon",
  3621	          "url": "https://www.patreon.com/feross"
  3622	        },
  3623	        {
  3624	          "type": "consulting",
  3625	          "url": "https://feross.org/support"
  3626	        }
  3627	      ],
  3628	      "license": "MIT"
  3629	    },
  3630	    "node_modules/react": {
  3631	      "version": "18.3.1",
  3632	      "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
  3633	      "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
  3634	      "license": "MIT",
  3635	      "dependencies": {
  3636	        "loose-envify": "^1.1.0"
  3637	      },
  3638	      "engines": {
  3639	        "node": ">=0.10.0"
  3640	      }
  3641	    },
  3642	    "node_modules/react-dom": {
  3643	      "version": "18.3.1",
  3644	      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
  3645	      "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
  3646	      "license": "MIT",
  3647	      "dependencies": {
  3648	        "loose-envify": "^1.1.0",
  3649	        "scheduler": "^0.23.2"
  3650	      },
  3651	      "peerDependencies": {
  3652	        "react": "^18.3.1"
  3653	      }
  3654	    },
  3655	    "node_modules/react-markdown": {
  3656	      "version": "9.1.0",
  3657	      "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz",
  3658	      "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==",
  3659	      "license": "MIT",
  3660	      "dependencies": {
  3661	        "@types/hast": "^3.0.0",
  3662	        "@types/mdast": "^4.0.0",
  3663	        "devlop": "^1.0.0",
  3664	        "hast-util-to-jsx-runtime": "^2.0.0",
  3665	        "html-url-attributes": "^3.0.0",
  3666	        "mdast-util-to-hast": "^13.0.0",
  3667	        "remark-parse": "^11.0.0",
  3668	        "remark-rehype": "^11.0.0",
  3669	        "unified": "^11.0.0",
  3670	        "unist-util-visit": "^5.0.0",
  3671	        "vfile": "^6.0.0"
  3672	      },
  3673	      "funding": {
  3674	        "type": "opencollective",
  3675	        "url": "https://opencollective.com/unified"
  3676	      },
  3677	      "peerDependencies": {
  3678	        "@types/react": ">=18",
  3679	        "react": ">=18"
  3680	      }
  3681	    },
  3682	    "node_modules/react-refresh": {
  3683	      "version": "0.17.0",
  3684	      "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
  3685	      "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
  3686	      "dev": true,
  3687	      "license": "MIT",
  3688	      "engines": {
  3689	        "node": ">=0.10.0"
  3690	      }
  3691	    },
  3692	    "node_modules/react-router": {
  3693	      "version": "7.13.1",
  3694	      "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
  3695	      "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
  3696	      "license": "MIT",
  3697	      "dependencies": {
  3698	        "cookie": "^1.0.1",
  3699	        "set-cookie-parser": "^2.6.0"
  3700	      },
  3701	      "engines": {
  3702	        "node": ">=20.0.0"
  3703	      },
  3704	      "peerDependencies": {
  3705	        "react": ">=18",
  3706	        "react-dom": ">=18"
  3707	      },
  3708	      "peerDependenciesMeta": {
  3709	        "react-dom": {
  3710	          "optional": true
  3711	        }
  3712	      }
  3713	    },
  3714	    "node_modules/react-router-dom": {
  3715	      "version": "7.13.1",
  3716	      "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
  3717	      "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
  3718	      "license": "MIT",
  3719	      "dependencies": {
  3720	        "react-router": "7.13.1"
  3721	      },
  3722	      "engines": {
  3723	        "node": ">=20.0.0"
  3724	      },
  3725	      "peerDependencies": {
  3726	        "react": ">=18",
  3727	        "react-dom": ">=18"
  3728	      }
  3729	    },
  3730	    "node_modules/react-syntax-highlighter": {
  3731	      "version": "15.6.6",
  3732	      "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.6.tgz",
  3733	      "integrity": "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw==",
  3734	      "license": "MIT",
  3735	      "dependencies": {
  3736	        "@babel/runtime": "^7.3.1",
  3737	        "highlight.js": "^10.4.1",
  3738	        "highlightjs-vue": "^1.0.0",
  3739	        "lowlight": "^1.17.0",
  3740	        "prismjs": "^1.30.0",
  3741	        "refractor": "^3.6.0"
  3742	      },
  3743	      "peerDependencies": {
  3744	        "react": ">= 0.14.0"
  3745	      }
  3746	    },
  3747	    "node_modules/read-cache": {
  3748	      "version": "1.0.0",
  3749	      "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
  3750	      "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
  3751	      "dev": true,
  3752	      "license": "MIT",
  3753	      "dependencies": {
  3754	        "pify": "^2.3.0"
  3755	      }
  3756	    },
  3757	    "node_modules/readdirp": {
  3758	      "version": "3.6.0",
  3759	      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
  3760	      "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
  3761	      "dev": true,
  3762	      "license": "MIT",
  3763	      "dependencies": {
  3764	        "picomatch": "^2.2.1"
  3765	      },
  3766	      "engines": {
  3767	        "node": ">=8.10.0"
  3768	      }
  3769	    },
  3770	    "node_modules/readdirp/node_modules/picomatch": {
  3771	      "version": "2.3.2",
  3772	      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
  3773	      "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
  3774	      "dev": true,
  3775	      "license": "MIT",
  3776	      "engines": {
  3777	        "node": ">=8.6"
  3778	      },
  3779	      "funding": {
  3780	        "url": "https://github.com/sponsors/jonschlinkert"
  3781	      }
  3782	    },
  3783	    "node_modules/refractor": {
  3784	      "version": "3.6.0",
  3785	      "resolved": "https://registry.npmjs.org/refractor/-/refractor-3.6.0.tgz",
  3786	      "integrity": "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==",
  3787	      "license": "MIT",
  3788	      "dependencies": {
  3789	        "hastscript": "^6.0.0",
  3790	        "parse-entities": "^2.0.0",
  3791	        "prismjs": "~1.27.0"
  3792	      },
  3793	      "funding": {
  3794	        "type": "github",
  3795	        "url": "https://github.com/sponsors/wooorm"
  3796	      }
  3797	    },
  3798	    "node_modules/refractor/node_modules/character-entities": {
  3799	      "version": "1.2.4",
  3800	      "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz",
  3801	      "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==",
  3802	      "license": "MIT",
  3803	      "funding": {
  3804	        "type": "github",
  3805	        "url": "https://github.com/sponsors/wooorm"
  3806	      }
  3807	    },
  3808	    "node_modules/refractor/node_modules/character-entities-legacy": {
  3809	      "version": "1.1.4",
  3810	      "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz",
  3811	      "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==",
  3812	      "license": "MIT",
  3813	      "funding": {
  3814	        "type": "github",
  3815	        "url": "https://github.com/sponsors/wooorm"
  3816	      }
  3817	    },
  3818	    "node_modules/refractor/node_modules/character-reference-invalid": {
  3819	      "version": "1.1.4",
  3820	      "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz",
  3821	      "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==",
  3822	      "license": "MIT",
  3823	      "funding": {
  3824	        "type": "github",
  3825	        "url": "https://github.com/sponsors/wooorm"
  3826	      }
  3827	    },
  3828	    "node_modules/refractor/node_modules/is-alphabetical": {
  3829	      "version": "1.0.4",
  3830	      "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz",
  3831	      "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==",
  3832	      "license": "MIT",
  3833	      "funding": {
  3834	        "type": "github",
  3835	        "url": "https://github.com/sponsors/wooorm"
  3836	      }
  3837	    },
  3838	    "node_modules/refractor/node_modules/is-alphanumerical": {
  3839	      "version": "1.0.4",
  3840	      "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz",
  3841	      "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==",
  3842	      "license": "MIT",
  3843	      "dependencies": {
  3844	        "is-alphabetical": "^1.0.0",
  3845	        "is-decimal": "^1.0.0"
  3846	      },
  3847	      "funding": {
  3848	        "type": "github",
  3849	        "url": "https://github.com/sponsors/wooorm"
  3850	      }
  3851	    },
  3852	    "node_modules/refractor/node_modules/is-decimal": {
  3853	      "version": "1.0.4",
  3854	      "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz",
  3855	      "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==",
  3856	      "license": "MIT",
  3857	      "funding": {
  3858	        "type": "github",
  3859	        "url": "https://github.com/sponsors/wooorm"
  3860	      }
  3861	    },
  3862	    "node_modules/refractor/node_modules/is-hexadecimal": {
  3863	      "version": "1.0.4",
  3864	      "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz",
  3865	      "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==",
  3866	      "license": "MIT",
  3867	      "funding": {
  3868	        "type": "github",
  3869	        "url": "https://github.com/sponsors/wooorm"
  3870	      }
  3871	    },
  3872	    "node_modules/refractor/node_modules/parse-entities": {
  3873	      "version": "2.0.0",
  3874	      "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz",
  3875	      "integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==",
  3876	      "license": "MIT",
  3877	      "dependencies": {
  3878	        "character-entities": "^1.0.0",
  3879	        "character-entities-legacy": "^1.0.0",
  3880	        "character-reference-invalid": "^1.0.0",
  3881	        "is-alphanumerical": "^1.0.0",
  3882	        "is-decimal": "^1.0.0",
  3883	        "is-hexadecimal": "^1.0.0"
  3884	      },
  3885	      "funding": {
  3886	        "type": "github",
  3887	        "url": "https://github.com/sponsors/wooorm"
  3888	      }
  3889	    },
  3890	    "node_modules/refractor/node_modules/prismjs": {
  3891	      "version": "1.27.0",
  3892	      "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.27.0.tgz",
  3893	      "integrity": "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==",
  3894	      "license": "MIT",
  3895	      "engines": {
  3896	        "node": ">=6"
  3897	      }
  3898	    },
  3899	    "node_modules/remark-gfm": {
  3900	      "version": "4.0.1",
  3901	      "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
  3902	      "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
  3903	      "license": "MIT",
  3904	      "dependencies": {
  3905	        "@types/mdast": "^4.0.0",
  3906	        "mdast-util-gfm": "^3.0.0",
  3907	        "micromark-extension-gfm": "^3.0.0",
  3908	        "remark-parse": "^11.0.0",
  3909	        "remark-stringify": "^11.0.0",
  3910	        "unified": "^11.0.0"
  3911	      },
  3912	      "funding": {
  3913	        "type": "opencollective",
  3914	        "url": "https://opencollective.com/unified"
  3915	      }
  3916	    },
  3917	    "node_modules/remark-parse": {
  3918	      "version": "11.0.0",
  3919	      "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
  3920	      "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==",
  3921	      "license": "MIT",
  3922	      "dependencies": {
  3923	        "@types/mdast": "^4.0.0",
  3924	        "mdast-util-from-markdown": "^2.0.0",
  3925	        "micromark-util-types": "^2.0.0",
  3926	        "unified": "^11.0.0"
  3927	      },
  3928	      "funding": {
  3929	        "type": "opencollective",
  3930	        "url": "https://opencollective.com/unified"
  3931	      }
  3932	    },
  3933	    "node_modules/remark-rehype": {
  3934	      "version": "11.1.2",
  3935	      "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz",
  3936	      "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
  3937	      "license": "MIT",
  3938	      "dependencies": {
  3939	        "@types/hast": "^3.0.0",
  3940	        "@types/mdast": "^4.0.0",
  3941	        "mdast-util-to-hast": "^13.0.0",
  3942	        "unified": "^11.0.0",
  3943	        "vfile": "^6.0.0"
  3944	      },
  3945	      "funding": {
  3946	        "type": "opencollective",
  3947	        "url": "https://opencollective.com/unified"
  3948	      }
  3949	    },
  3950	    "node_modules/remark-stringify": {
  3951	      "version": "11.0.0",
  3952	      "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
  3953	      "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
  3954	      "license": "MIT",
  3955	      "dependencies": {
  3956	        "@types/mdast": "^4.0.0",
  3957	        "mdast-util-to-markdown": "^2.0.0",
  3958	        "unified": "^11.0.0"
  3959	      },
  3960	      "funding": {
  3961	        "type": "opencollective",
  3962	        "url": "https://opencollective.com/unified"
  3963	      }
  3964	    },
  3965	    "node_modules/resolve": {
  3966	      "version": "1.22.11",
  3967	      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
  3968	      "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
  3969	      "dev": true,
  3970	      "license": "MIT",
  3971	      "dependencies": {
  3972	        "is-core-module": "^2.16.1",
  3973	        "path-parse": "^1.0.7",
  3974	        "supports-preserve-symlinks-flag": "^1.0.0"
  3975	      },
  3976	      "bin": {
  3977	        "resolve": "bin/resolve"
  3978	      },
  3979	      "engines": {
  3980	        "node": ">= 0.4"
  3981	      },
  3982	      "funding": {
  3983	        "url": "https://github.com/sponsors/ljharb"
  3984	      }
  3985	    },
  3986	    "node_modules/reusify": {
  3987	      "version": "1.1.0",
  3988	      "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
  3989	      "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
  3990	      "dev": true,
  3991	      "license": "MIT",
  3992	      "engines": {
  3993	        "iojs": ">=1.0.0",
  3994	        "node": ">=0.10.0"
  3995	      }
  3996	    },
  3997	    "node_modules/rollup": {
  3998	      "version": "4.59.0",
  3999	      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
  4000	      "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
  4001	      "dev": true,
  4002	      "license": "MIT",
  4003	      "dependencies": {
  4004	        "@types/estree": "1.0.8"
  4005	      },
  4006	      "bin": {
  4007	        "rollup": "dist/bin/rollup"
  4008	      },
  4009	      "engines": {
  4010	        "node": ">=18.0.0",
  4011	        "npm": ">=8.0.0"
  4012	      },
  4013	      "optionalDependencies": {
  4014	        "@rollup/rollup-android-arm-eabi": "4.59.0",
  4015	        "@rollup/rollup-android-arm64": "4.59.0",
  4016	        "@rollup/rollup-darwin-arm64": "4.59.0",
  4017	        "@rollup/rollup-darwin-x64": "4.59.0",
  4018	        "@rollup/rollup-freebsd-arm64": "4.59.0",
  4019	        "@rollup/rollup-freebsd-x64": "4.59.0",
  4020	        "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
  4021	        "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
  4022	        "@rollup/rollup-linux-arm64-gnu": "4.59.0",
  4023	        "@rollup/rollup-linux-arm64-musl": "4.59.0",
  4024	        "@rollup/rollup-linux-loong64-gnu": "4.59.0",
  4025	        "@rollup/rollup-linux-loong64-musl": "4.59.0",
  4026	        "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
  4027	        "@rollup/rollup-linux-ppc64-musl": "4.59.0",
  4028	        "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
  4029	        "@rollup/rollup-linux-riscv64-musl": "4.59.0",
  4030	        "@rollup/rollup-linux-s390x-gnu": "4.59.0",
  4031	        "@rollup/rollup-linux-x64-gnu": "4.59.0",
  4032	        "@rollup/rollup-linux-x64-musl": "4.59.0",
  4033	        "@rollup/rollup-openbsd-x64": "4.59.0",
  4034	        "@rollup/rollup-openharmony-arm64": "4.59.0",
  4035	        "@rollup/rollup-win32-arm64-msvc": "4.59.0",
  4036	        "@rollup/rollup-win32-ia32-msvc": "4.59.0",
  4037	        "@rollup/rollup-win32-x64-gnu": "4.59.0",
  4038	        "@rollup/rollup-win32-x64-msvc": "4.59.0",
  4039	        "fsevents": "~2.3.2"
  4040	      }
  4041	    },
  4042	    "node_modules/run-parallel": {
  4043	      "version": "1.2.0",
  4044	      "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
  4045	      "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
  4046	      "dev": true,
  4047	      "funding": [
  4048	        {
  4049	          "type": "github",
  4050	          "url": "https://github.com/sponsors/feross"
  4051	        },
  4052	        {
  4053	          "type": "patreon",
  4054	          "url": "https://www.patreon.com/feross"
  4055	        },
  4056	        {
  4057	          "type": "consulting",
  4058	          "url": "https://feross.org/support"
  4059	        }
  4060	      ],
  4061	      "license": "MIT",
  4062	      "dependencies": {
  4063	        "queue-microtask": "^1.2.2"
  4064	      }
  4065	    },
  4066	    "node_modules/scheduler": {
  4067	      "version": "0.23.2",
  4068	      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
  4069	      "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
  4070	      "license": "MIT",
  4071	      "dependencies": {
  4072	        "loose-envify": "^1.1.0"
  4073	      }
  4074	    },
  4075	    "node_modules/semver": {
  4076	      "version": "6.3.1",
  4077	      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
  4078	      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
  4079	      "dev": true,
  4080	      "license": "ISC",
  4081	      "bin": {
  4082	        "semver": "bin/semver.js"
  4083	      }
  4084	    },
  4085	    "node_modules/set-cookie-parser": {
  4086	      "version": "2.7.2",
  4087	      "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
  4088	      "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
  4089	      "license": "MIT"
  4090	    },
  4091	    "node_modules/source-map-js": {
  4092	      "version": "1.2.1",
  4093	      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
  4094	      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
  4095	      "dev": true,
  4096	      "license": "BSD-3-Clause",
  4097	      "engines": {
  4098	        "node": ">=0.10.0"
  4099	      }
  4100	    },
  4101	    "node_modules/space-separated-tokens": {
  4102	      "version": "2.0.2",
  4103	      "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
  4104	      "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
  4105	      "license": "MIT",
  4106	      "funding": {
  4107	        "type": "github",
  4108	        "url": "https://github.com/sponsors/wooorm"
  4109	      }
  4110	    },
  4111	    "node_modules/stringify-entities": {
  4112	      "version": "4.0.4",
  4113	      "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
  4114	      "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
  4115	      "license": "MIT",
  4116	      "dependencies": {
  4117	        "character-entities-html4": "^2.0.0",
  4118	        "character-entities-legacy": "^3.0.0"
  4119	      },
  4120	      "funding": {
  4121	        "type": "github",
  4122	        "url": "https://github.com/sponsors/wooorm"
  4123	      }
  4124	    },
  4125	    "node_modules/style-to-js": {
  4126	      "version": "1.1.21",
  4127	      "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
  4128	      "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
  4129	      "license": "MIT",
  4130	      "dependencies": {
  4131	        "style-to-object": "1.0.14"
  4132	      }
  4133	    },
  4134	    "node_modules/style-to-object": {
  4135	      "version": "1.0.14",
  4136	      "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
  4137	      "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
  4138	      "license": "MIT",
  4139	      "dependencies": {
  4140	        "inline-style-parser": "0.2.7"
  4141	      }
  4142	    },
  4143	    "node_modules/sucrase": {
  4144	      "version": "3.35.1",
  4145	      "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
  4146	      "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
  4147	      "dev": true,
  4148	      "license": "MIT",
  4149	      "dependencies": {
  4150	        "@jridgewell/gen-mapping": "^0.3.2",
  4151	        "commander": "^4.0.0",
  4152	        "lines-and-columns": "^1.1.6",
  4153	        "mz": "^2.7.0",
  4154	        "pirates": "^4.0.1",
  4155	        "tinyglobby": "^0.2.11",
  4156	        "ts-interface-checker": "^0.1.9"
  4157	      },
  4158	      "bin": {
  4159	        "sucrase": "bin/sucrase",
  4160	        "sucrase-node": "bin/sucrase-node"
  4161	      },
  4162	      "engines": {
  4163	        "node": ">=16 || 14 >=14.17"
  4164	      }
  4165	    },
  4166	    "node_modules/supports-preserve-symlinks-flag": {
  4167	      "version": "1.0.0",
  4168	      "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
  4169	      "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
  4170	      "dev": true,
  4171	      "license": "MIT",
  4172	      "engines": {
  4173	        "node": ">= 0.4"
  4174	      },
  4175	      "funding": {
  4176	        "url": "https://github.com/sponsors/ljharb"
  4177	      }
  4178	    },
  4179	    "node_modules/tailwindcss": {
  4180	      "version": "3.4.19",
  4181	      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
  4182	      "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
  4183	      "dev": true,
  4184	      "license": "MIT",
  4185	      "dependencies": {
  4186	        "@alloc/quick-lru": "^5.2.0",
  4187	        "arg": "^5.0.2",
  4188	        "chokidar": "^3.6.0",
  4189	        "didyoumean": "^1.2.2",
  4190	        "dlv": "^1.1.3",
  4191	        "fast-glob": "^3.3.2",
  4192	        "glob-parent": "^6.0.2",
  4193	        "is-glob": "^4.0.3",
  4194	        "jiti": "^1.21.7",
  4195	        "lilconfig": "^3.1.3",
  4196	        "micromatch": "^4.0.8",
  4197	        "normalize-path": "^3.0.0",
  4198	        "object-hash": "^3.0.0",
  4199	        "picocolors": "^1.1.1",
  4200	        "postcss": "^8.4.47",
  4201	        "postcss-import": "^15.1.0",
  4202	        "postcss-js": "^4.0.1",
  4203	        "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
  4204	        "postcss-nested": "^6.2.0",
  4205	        "postcss-selector-parser": "^6.1.2",
  4206	        "resolve": "^1.22.8",
  4207	        "sucrase": "^3.35.0"
  4208	      },
  4209	      "bin": {
  4210	        "tailwind": "lib/cli.js",
  4211	        "tailwindcss": "lib/cli.js"
  4212	      },
  4213	      "engines": {
  4214	        "node": ">=14.0.0"
  4215	      }
  4216	    },
  4217	    "node_modules/thenify": {
  4218	      "version": "3.3.1",
  4219	      "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
  4220	      "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
  4221	      "dev": true,
  4222	      "license": "MIT",
  4223	      "dependencies": {
  4224	        "any-promise": "^1.0.0"
  4225	      }
  4226	    },
  4227	    "node_modules/thenify-all": {
  4228	      "version": "1.6.0",
  4229	      "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
  4230	      "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
  4231	      "dev": true,
  4232	      "license": "MIT",
  4233	      "dependencies": {
  4234	        "thenify": ">= 3.1.0 < 4"
  4235	      },
  4236	      "engines": {
  4237	        "node": ">=0.8"
  4238	      }
  4239	    },
  4240	    "node_modules/tinyglobby": {
  4241	      "version": "0.2.15",
  4242	      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
  4243	      "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
  4244	      "dev": true,
  4245	      "license": "MIT",
  4246	      "dependencies": {
  4247	        "fdir": "^6.5.0",
  4248	        "picomatch": "^4.0.3"
  4249	      },
  4250	      "engines": {
  4251	        "node": ">=12.0.0"
  4252	      },
  4253	      "funding": {
  4254	        "url": "https://github.com/sponsors/SuperchupuDev"
  4255	      }
  4256	    },
  4257	    "node_modules/to-regex-range": {
  4258	      "version": "5.0.1",
  4259	      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
  4260	      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
  4261	      "dev": true,
  4262	      "license": "MIT",
  4263	      "dependencies": {
  4264	        "is-number": "^7.0.0"
  4265	      },
  4266	      "engines": {
  4267	        "node": ">=8.0"
  4268	      }
  4269	    },
  4270	    "node_modules/trim-lines": {
  4271	      "version": "3.0.1",
  4272	      "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
  4273	      "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
  4274	      "license": "MIT",
  4275	      "funding": {
  4276	        "type": "github",
  4277	        "url": "https://github.com/sponsors/wooorm"
  4278	      }
  4279	    },
  4280	    "node_modules/trough": {
  4281	      "version": "2.2.0",
  4282	      "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
  4283	      "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==",
  4284	      "license": "MIT",
  4285	      "funding": {
  4286	        "type": "github",
  4287	        "url": "https://github.com/sponsors/wooorm"
  4288	      }
  4289	    },
  4290	    "node_modules/ts-interface-checker": {
  4291	      "version": "0.1.13",
  4292	      "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
  4293	      "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
  4294	      "dev": true,
  4295	      "license": "Apache-2.0"
  4296	    },
  4297	    "node_modules/unified": {
  4298	      "version": "11.0.5",
  4299	      "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
  4300	      "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
  4301	      "license": "MIT",
  4302	      "dependencies": {
  4303	        "@types/unist": "^3.0.0",
  4304	        "bail": "^2.0.0",
  4305	        "devlop": "^1.0.0",
  4306	        "extend": "^3.0.0",
  4307	        "is-plain-obj": "^4.0.0",
  4308	        "trough": "^2.0.0",
  4309	        "vfile": "^6.0.0"
  4310	      },
  4311	      "funding": {
  4312	        "type": "opencollective",
  4313	        "url": "https://opencollective.com/unified"
  4314	      }
  4315	    },
  4316	    "node_modules/unist-util-is": {
  4317	      "version": "6.0.1",
  4318	      "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
  4319	      "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
  4320	      "license": "MIT",
  4321	      "dependencies": {
  4322	        "@types/unist": "^3.0.0"
  4323	      },
  4324	      "funding": {
  4325	        "type": "opencollective",
  4326	        "url": "https://opencollective.com/unified"
  4327	      }
  4328	    },
  4329	    "node_modules/unist-util-position": {
  4330	      "version": "5.0.0",
  4331	      "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
  4332	      "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
  4333	      "license": "MIT",
  4334	      "dependencies": {
  4335	        "@types/unist": "^3.0.0"
  4336	      },
  4337	      "funding": {
  4338	        "type": "opencollective",
  4339	        "url": "https://opencollective.com/unified"
  4340	      }
  4341	    },
  4342	    "node_modules/unist-util-stringify-position": {
  4343	      "version": "4.0.0",
  4344	      "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
  4345	      "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
  4346	      "license": "MIT",
  4347	      "dependencies": {
  4348	        "@types/unist": "^3.0.0"
  4349	      },
  4350	      "funding": {
  4351	        "type": "opencollective",
  4352	        "url": "https://opencollective.com/unified"
  4353	      }
  4354	    },
  4355	    "node_modules/unist-util-visit": {
  4356	      "version": "5.1.0",
  4357	      "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
  4358	      "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
  4359	      "license": "MIT",
  4360	      "dependencies": {
  4361	        "@types/unist": "^3.0.0",
  4362	        "unist-util-is": "^6.0.0",
  4363	        "unist-util-visit-parents": "^6.0.0"
  4364	      },
  4365	      "funding": {
  4366	        "type": "opencollective",
  4367	        "url": "https://opencollective.com/unified"
  4368	      }
  4369	    },
  4370	    "node_modules/unist-util-visit-parents": {
  4371	      "version": "6.0.2",
  4372	      "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
  4373	      "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
  4374	      "license": "MIT",
  4375	      "dependencies": {
  4376	        "@types/unist": "^3.0.0",
  4377	        "unist-util-is": "^6.0.0"
  4378	      },
  4379	      "funding": {
  4380	        "type": "opencollective",
  4381	        "url": "https://opencollective.com/unified"
  4382	      }
  4383	    },
  4384	    "node_modules/update-browserslist-db": {
  4385	      "version": "1.2.3",
  4386	      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
  4387	      "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
  4388	      "dev": true,
  4389	      "funding": [
  4390	        {
  4391	          "type": "opencollective",
  4392	          "url": "https://opencollective.com/browserslist"
  4393	        },
  4394	        {
  4395	          "type": "tidelift",
  4396	          "url": "https://tidelift.com/funding/github/npm/browserslist"
  4397	        },
  4398	        {
  4399	          "type": "github",
  4400	          "url": "https://github.com/sponsors/ai"
  4401	        }
  4402	      ],
  4403	      "license": "MIT",
  4404	      "dependencies": {
  4405	        "escalade": "^3.2.0",
  4406	        "picocolors": "^1.1.1"
  4407	      },
  4408	      "bin": {
  4409	        "update-browserslist-db": "cli.js"
  4410	      },
  4411	      "peerDependencies": {
  4412	        "browserslist": ">= 4.21.0"
  4413	      }
  4414	    },
  4415	    "node_modules/util-deprecate": {
  4416	      "version": "1.0.2",
  4417	      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
  4418	      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
  4419	      "dev": true,
  4420	      "license": "MIT"
  4421	    },
  4422	    "node_modules/vfile": {
  4423	      "version": "6.0.3",
  4424	      "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
  4425	      "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
  4426	      "license": "MIT",
  4427	      "dependencies": {
  4428	        "@types/unist": "^3.0.0",
  4429	        "vfile-message": "^4.0.0"
  4430	      },
  4431	      "funding": {
  4432	        "type": "opencollective",
  4433	        "url": "https://opencollective.com/unified"
  4434	      }
  4435	    },
  4436	    "node_modules/vfile-message": {
  4437	      "version": "4.0.3",
  4438	      "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
  4439	      "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
  4440	      "license": "MIT",
  4441	      "dependencies": {
  4442	        "@types/unist": "^3.0.0",
  4443	        "unist-util-stringify-position": "^4.0.0"
  4444	      },
  4445	      "funding": {
  4446	        "type": "opencollective",
  4447	        "url": "https://opencollective.com/unified"
  4448	      }
  4449	    },
  4450	    "node_modules/vite": {
  4451	      "version": "6.4.1",
  4452	      "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
  4453	      "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
  4454	      "dev": true,
  4455	      "license": "MIT",
  4456	      "dependencies": {
  4457	        "esbuild": "^0.25.0",
  4458	        "fdir": "^6.4.4",
  4459	        "picomatch": "^4.0.2",
  4460	        "postcss": "^8.5.3",
  4461	        "rollup": "^4.34.9",
  4462	        "tinyglobby": "^0.2.13"
  4463	      },
  4464	      "bin": {
  4465	        "vite": "bin/vite.js"
  4466	      },
  4467	      "engines": {
  4468	        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
  4469	      },
  4470	      "funding": {
  4471	        "url": "https://github.com/vitejs/vite?sponsor=1"
  4472	      },
  4473	      "optionalDependencies": {
  4474	        "fsevents": "~2.3.3"
  4475	      },
  4476	      "peerDependencies": {
  4477	        "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
  4478	        "jiti": ">=1.21.0",
  4479	        "less": "*",
  4480	        "lightningcss": "^1.21.0",
  4481	        "sass": "*",
  4482	        "sass-embedded": "*",
  4483	        "stylus": "*",
  4484	        "sugarss": "*",
  4485	        "terser": "^5.16.0",
  4486	        "tsx": "^4.8.1",
  4487	        "yaml": "^2.4.2"
  4488	      },
  4489	      "peerDependenciesMeta": {
  4490	        "@types/node": {
  4491	          "optional": true
  4492	        },
  4493	        "jiti": {
  4494	          "optional": true
  4495	        },
  4496	        "less": {
  4497	          "optional": true
  4498	        },
  4499	        "lightningcss": {
  4500	          "optional": true
  4501	        },
  4502	        "sass": {
  4503	          "optional": true
  4504	        },
  4505	        "sass-embedded": {
  4506	          "optional": true
  4507	        },
  4508	        "stylus": {
  4509	          "optional": true
  4510	        },
  4511	        "sugarss": {
  4512	          "optional": true
  4513	        },
  4514	        "terser": {
  4515	          "optional": true
  4516	        },
  4517	        "tsx": {
  4518	          "optional": true
  4519	        },
  4520	        "yaml": {
  4521	          "optional": true
  4522	        }
  4523	      }
  4524	    },
  4525	    "node_modules/xtend": {
  4526	      "version": "4.0.2",
  4527	      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
  4528	      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
  4529	      "license": "MIT",
  4530	      "engines": {
  4531	        "node": ">=0.4"
  4532	      }
  4533	    },
  4534	    "node_modules/yallist": {
  4535	      "version": "3.1.1",
  4536	      "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
  4537	      "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
  4538	      "dev": true,
  4539	      "license": "ISC"
  4540	    },
  4541	    "node_modules/zwitch": {
  4542	      "version": "2.0.4",
  4543	      "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
  4544	      "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
  4545	      "license": "MIT",
  4546	      "funding": {
  4547	        "type": "github",
  4548	        "url": "https://github.com/sponsors/wooorm"
  4549	      }
  4550	    }
  4551	  }
  4552	}
│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [041]: frontend/package-lock.json


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [042/69]: frontend/package.json
│ LANGUAGE: json | LINES: 27 | SIZE: 666 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	{
     2	  "name": "son-of-anton-frontend",
     3	  "version": "1.0.0",
     4	  "scripts": {
     5	    "dev": "vite",
     6	    "build": "vite build",
     7	    "preview": "vite preview"
     8	  },
     9	  "dependencies": {
    10	    "lucide-react": "^0.469.0",
    11	    "react": "^18.3.1",
    12	    "react-dom": "^18.3.1",
    13	    "react-markdown": "^9.0.1",
    14	    "react-router-dom": "^7.1.1",
    15	    "react-syntax-highlighter": "^15.6.1",
    16	    "remark-gfm": "^4.0.0",
    17	    "mermaid": "^11.4.0",
    18	    "html-to-image": "^1.11.11"
    19	  },
    20	  "devDependencies": {
    21	    "@types/react": "^18.3.18",
    22	    "@vitejs/plugin-react": "^4.3.4",
    23	    "autoprefixer": "^10.4.20",
    24	    "postcss": "^8.4.49",
    25	    "tailwindcss": "^3.4.17",
    26	    "vite": "^6.0.7"
    27	  }
    28	}│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [042]: frontend/package.json


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [043/69]: frontend/postcss.config.js
│ LANGUAGE: javascript | LINES: 5 | SIZE: 80 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	export default {
     2	  plugins: {
     3	    tailwindcss: {},
     4	    autoprefixer: {},
     5	  },
     6	};│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [043]: frontend/postcss.config.js


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [044/69]: frontend/src/App.jsx
│ LANGUAGE: jsx | LINES: 53 | SIZE: 1933 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	import React, { useEffect, useState } from "react";
     2	import { Routes, Route } from "react-router-dom";
     3	import { useApp } from "./store";
     4	import { getMe } from "./api";
     5	import * as streamManager from "./streamManager";
     6	import LoginPage from "./pages/LoginPage";
     7	import ChatPage from "./pages/ChatPage";
     8	import AdminPage from "./pages/AdminPage";
     9	import KnowledgePage from "./pages/KnowledgePage";
    10	import GitLabPage from "./pages/GitLabPage";
    11	import { Flame } from "lucide-react";
    12	
    13	export default function App() {
    14	  const { state, dispatch } = useApp();
    15	  const [authChecked, setAuthChecked] = useState(!state.token);
    16	
    17	  useEffect(() => { streamManager.setDispatch(dispatch); }, [dispatch]);
    18	
    19	  useEffect(() => {
    20	    if (!state.token) { setAuthChecked(true); return; }
    21	    if (state.user) { setAuthChecked(true); return; }
    22	    (async () => {
    23	      try {
    24	        const user = await getMe(state.token);
    25	        dispatch({ type: "SET_USER", user });
    26	      } catch { dispatch({ type: "LOGOUT" }); }
    27	      finally { setAuthChecked(true); }
    28	    })();
    29	  }, [state.token, state.user, dispatch]);
    30	
    31	  if (!authChecked) {
    32	    return (
    33	      <div className="h-dvh flex items-center justify-center bg-anton-bg">
    34	        <div className="flex flex-col items-center gap-4 animate-fade-in">
    35	          <div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20">
    36	            <Flame size={32} className="text-white animate-pulse" />
    37	          </div>
    38	          <p className="text-anton-muted text-sm">Loading...</p>
    39	        </div>
    40	      </div>
    41	    );
    42	  }
    43	
    44	  if (!state.token) return <LoginPage />;
    45	
    46	  return (
    47	    <Routes>
    48	      <Route path="/admin" element={<AdminPage />} />
    49	      <Route path="/knowledge" element={<KnowledgePage />} />
    50	      <Route path="/gitlab" element={<GitLabPage />} />
    51	      <Route path="/*" element={<ChatPage />} />
    52	    </Routes>
    53	  );
    54	}│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [044]: frontend/src/App.jsx


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [045/69]: frontend/src/api.js
│ LANGUAGE: javascript | LINES: 281 | SIZE: 12998 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	const BASE = "/api";
     2	
     3	function headers(token) {
     4	  const h = { "Content-Type": "application/json" };
     5	  if (token) h["Authorization"] = `Bearer ${token}`;
     6	  return h;
     7	}
     8	
     9	function authHeader(token) {
    10	  return token ? { Authorization: `Bearer ${token}` } : {};
    11	}
    12	
    13	async function request(method, path, token, body) {
    14	  const opts = { method, headers: headers(token) };
    15	  if (body) opts.body = JSON.stringify(body);
    16	  const res = await fetch(`${BASE}${path}`, opts);
    17	  if (!res.ok) {
    18	    const err = await res.json().catch(() => ({ detail: res.statusText }));
    19	    throw new Error(err.detail || err.message || "Request failed");
    20	  }
    21	  return res.json();
    22	}
    23	
    24	// ═══════════════════════════════════════
    25	//  Utility: Extract fenced code blocks from markdown
    26	// ═══════════════════════════════════════
    27	
    28	export function extractCodeBlocks(markdown) {
    29	  if (!markdown) return [];
    30	  const blocks = [];
    31	  const re = /```([\w.:-]*)[ \t]*\r?\n([\s\S]*?)```/g;
    32	  let match;
    33	  while ((match = re.exec(markdown)) !== null) {
    34	    const raw = match[1] || "";
    35	    let lang = raw, filename = null;
    36	    const colonIdx = raw.indexOf(":");
    37	    if (colonIdx !== -1) {
    38	      lang = raw.slice(0, colonIdx);
    39	      filename = raw.slice(colonIdx + 1) || null;
    40	    }
    41	    blocks.push({ language: lang.toLowerCase(), filename, code: match[2] });
    42	  }
    43	  return blocks;
    44	}
    45	
    46	
    47	// ═══════════════════════════════════════
    48	//  Auth
    49	// ═══════════════════════════════════════
    50	
    51	export const getRegistrationStatus = () => request("GET", "/auth/registration-status", null);
    52	
    53	export const login = (username, password) =>
    54	  request("POST", "/auth/login", null, { username, password });
    55	
    56	export const register = (username, email, password) =>
    57	  request("POST", "/auth/register", null, { username, email, password });
    58	
    59	export const getMe = (token) => request("GET", "/auth/me", token);
    60	
    61	// ═══════════════════════════════════════
    62	//  Chats
    63	// ═══════════════════════════════════════
    64	
    65	export const listChats = (token) => request("GET", "/chats", token);
    66	
    67	export const createChat = (token, data = {}) => request("POST", "/chats", token, data);
    68	
    69	export const updateChat = (token, chatId, data) =>
    70	  request("PUT", `/chats/${chatId}`, token, data);
    71	
    72	export const renameChat = (token, chatId, title) =>
    73	  updateChat(token, chatId, { title });
    74	
    75	export const deleteChat = (token, chatId) =>
    76	  request("DELETE", `/chats/${chatId}`, token);
    77	
    78	export const getMessages = (token, chatId) =>
    79	  request("GET", `/chats/${chatId}/messages`, token);
    80	
    81	export async function* streamMessage(token, chatId, body, signal) {
    82	  const res = await fetch(`${BASE}/chats/${chatId}/messages`, {
    83	    method: "POST", headers: headers(token),
    84	    body: JSON.stringify(body), signal,
    85	  });
    86	  if (!res.ok) {
    87	    const err = await res.json().catch(() => ({ detail: res.statusText }));
    88	    throw new Error(err.detail || "Stream failed");
    89	  }
    90	  const reader = res.body.getReader();
    91	  const decoder = new TextDecoder();
    92	  let buffer = "";
    93	  while (true) {
    94	    const { done, value } = await reader.read();
    95	    if (done) break;
    96	    buffer += decoder.decode(value, { stream: true });
    97	    const parts = buffer.split("\n\n");
    98	    buffer = parts.pop() || "";
    99	    for (const part of parts) {
   100	      const line = part.trim();
   101	      if (line.startsWith("data: ")) {
   102	        try { yield JSON.parse(line.slice(6)); } catch { }
   103	      }
   104	    }
   105	  }
   106	  if (buffer.trim().startsWith("data: ")) {
   107	    try { yield JSON.parse(buffer.trim().slice(6)); } catch { }
   108	  }
   109	}
   110	
   111	export const checkGenerating = (token, chatId) =>
   112	  request("GET", `/chats/${chatId}/generating`, token);
   113	
   114	export async function commitFromChat(token, chatId, data) {
   115	  return request("POST", `/chats/${chatId}/commit`, token, data);
   116	}
   117	
   118	export const refreshRepoContext = (token, chatId) =>
   119	  request("POST", `/chats/${chatId}/refresh-repo`, token);
   120	
   121	// ═══════════════════════════════════════
   122	//  Attachments
   123	// ═══════════════════════════════════════
   124	
   125	export async function uploadAttachments(token, chatId, files) {
   126	  const form = new FormData();
   127	  for (const file of files) form.append("files", file);
   128	  const res = await fetch(`${BASE}/chats/${chatId}/attachments`, {
   129	    method: "POST", headers: authHeader(token), body: form,
   130	  });
   131	  if (!res.ok) {
   132	    const err = await res.json().catch(() => ({}));
   133	    throw new Error(err.detail || "Upload failed");
   134	  }
   135	  return res.json();
   136	}
   137	
   138	export function getAttachmentUrl(attachmentId) {
   139	  return `${BASE}/attachments/${attachmentId}/file`;
   140	}
   141	
   142	export const deleteAttachment = (token, attachmentId) =>
   143	  request("DELETE", `/attachments/${attachmentId}`, token);
   144	
   145	// ═══════════════════════════════════════
   146	//  Knowledge Base
   147	// ═══════════════════════════════════════
   148	
   149	export const listKnowledgeBases = (token) => request("GET", "/knowledge", token);
   150	
   151	export const createKnowledgeBase = (token, name, description = "") =>
   152	  request("POST", "/knowledge", token, { name, description });
   153	
   154	export const getKnowledgeBase = (token, kbId) =>
   155	  request("GET", `/knowledge/${kbId}`, token);
   156	
   157	export const deleteKnowledgeBase = (token, kbId) =>
   158	  request("DELETE", `/knowledge/${kbId}`, token);
   159	
   160	export const updateKnowledgeBase = (token, kbId, data) =>
   161	  request("PUT", `/knowledge/${kbId}`, token, data);
   162	
   163	export const deleteKnowledgeDocument = (token, kbId, docId) =>
   164	  request("DELETE", `/knowledge/${kbId}/documents/${docId}`, token);
   165	
   166	export async function uploadDocuments(token, kbId, files) {
   167	  const form = new FormData();
   168	  for (const file of files) form.append("files", file);
   169	  const res = await fetch(`${BASE}/knowledge/${kbId}/upload`, {
   170	    method: "POST", headers: authHeader(token), body: form,
   171	  });
   172	  if (!res.ok) {
   173	    const err = await res.json().catch(() => ({}));
   174	    throw new Error(err.detail || "Upload failed");
   175	  }
   176	  return res.json();
   177	}
   178	
   179	export const uploadDocument = (token, kbId, file) =>
   180	  uploadDocuments(token, kbId, [file]);
   181	
   182	// ═══════════════════════════════════════
   183	//  Admin
   184	// ═══════════════════════════════════════
   185	
   186	export const adminStats = (token) => request("GET", "/admin/stats", token);
   187	export const adminListUsers = (token) => request("GET", "/admin/users", token);
   188	export const adminCreateUser = (token, data) => request("POST", "/admin/users", token, data);
   189	export const adminUpdateUser = (token, userId, data) => request("PUT", `/admin/users/${userId}`, token, data);
   190	export const adminDeleteUser = (token, userId) => request("DELETE", `/admin/users/${userId}`, token);
   191	export const adminListChats = (token) => request("GET", "/admin/chats", token);
   192	
   193	export const adminGetAppSettings = (token) => request("GET", "/admin/app-settings", token);
   194	export const adminUpdateAppSettings = (token, data) => request("PUT", "/admin/app-settings", token, data);
   195	
   196	export const adminGetPermissionDefaults = (token) => request("GET", "/admin/permissions/defaults", token);
   197	export const adminUpdatePermissionDefaults = (token, data) => request("PUT", "/admin/permissions/defaults", token, data);
   198	export const adminApplyDefaults = (token) => request("POST", "/admin/permissions/apply-defaults", token);
   199	export const adminGetUserPermissions = (token, userId) => request("GET", `/admin/users/${userId}/permissions`, token);
   200	export const adminUpdateUserPermissions = (token, userId, data) => request("PUT", `/admin/users/${userId}/permissions`, token, data);
   201	export const adminListModels = (token) => request("GET", "/admin/models", token);
   202	
   203	// ═══════════════════════════════════════
   204	//  Files / Export
   205	// ═══════════════════════════════════════
   206	
   207	export async function downloadZip(token, markdown, title) {
   208	  const res = await fetch(`${BASE}/files/download-zip`, {
   209	    method: "POST", headers: headers(token),
   210	    body: JSON.stringify({ markdown, title }),
   211	  });
   212	  if (!res.ok) throw new Error("Download failed");
   213	  const ct = res.headers.get("content-type") || "";
   214	  if (ct.includes("application/zip")) {
   215	    const blob = await res.blob();
   216	    const url = URL.createObjectURL(blob);
   217	    const a = document.createElement("a");
   218	    a.href = url;
   219	    a.download = "son-of-anton-code.zip";
   220	    a.click();
   221	    URL.revokeObjectURL(url);
   222	  } else {
   223	    const data = await res.json();
   224	    if (data.error) throw new Error(data.error);
   225	  }
   226	}
   227	
   228	export async function exportPptx(token, markdown, title) {
   229	  const res = await fetch(`${BASE}/export/pptx`, {
   230	    method: "POST", headers: headers(token),
   231	    body: JSON.stringify({ markdown, title }),
   232	  });
   233	  if (!res.ok) throw new Error("Export failed");
   234	  const blob = await res.blob();
   235	  const url = URL.createObjectURL(blob);
   236	  const a = document.createElement("a");
   237	  a.href = url;
   238	  a.download = (title || "presentation") + ".pptx";
   239	  a.click();
   240	  URL.revokeObjectURL(url);
   241	}
   242	
   243	export async function exportDocx(token, markdown, title) {
   244	  const res = await fetch(`${BASE}/export/docx`, {
   245	    method: "POST", headers: headers(token),
   246	    body: JSON.stringify({ markdown, title }),
   247	  });
   248	  if (!res.ok) throw new Error("Export failed");
   249	  const blob = await res.blob();
   250	  const url = URL.createObjectURL(blob);
   251	  const a = document.createElement("a");
   252	  a.href = url;
   253	  a.download = (title || "document") + ".docx";
   254	  a.click();
   255	  URL.revokeObjectURL(url);
   256	}
   257	
   258	// ═══════════════════════════════════════
   259	//  GitLab
   260	// ═══════════════════════════════════════
   261	
   262	export const gitlabGetSettings = (token) => request("GET", "/gitlab/settings", token);
   263	export const gitlabUpdateSettings = (token, data) => request("PUT", "/gitlab/settings", token, data);
   264	export const gitlabTestConnection = (token) => request("POST", "/gitlab/test-connection", token);
   265	export const gitlabSearchProjects = (token, search, owned) => request("GET", `/gitlab/projects?search=${encodeURIComponent(search || "")}&owned=${owned || false}`, token);
   266	export const gitlabCreateProject = (token, data) => request("POST", "/gitlab/projects", token, data);
   267	export const gitlabListRepos = (token) => request("GET", "/gitlab/repos", token);
   268	export const gitlabLinkRepo = (token, projectId) => request("POST", "/gitlab/repos", token, { gitlab_project_id: projectId });
   269	export const gitlabUnlinkRepo = (token, repoId) => request("DELETE", `/gitlab/repos/${repoId}`, token);
   270	export const gitlabGetTree = (token, repoId, path, ref) => request("GET", `/gitlab/repos/${repoId}/tree?path=${encodeURIComponent(path || "")}&ref=${encodeURIComponent(ref || "")}`, token);
   271	export const gitlabGetFile = (token, repoId, path, ref) => request("GET", `/gitlab/repos/${repoId}/file?path=${encodeURIComponent(path)}&ref=${encodeURIComponent(ref || "")}`, token);
   272	export const gitlabGetBranches = (token, repoId) => request("GET", `/gitlab/repos/${repoId}/branches`, token);
   273	export const gitlabCommit = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/commit`, token, data);
   274	export const gitlabCommitSingle = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/commit-single`, token, data);
   275	export const gitlabCreateBranch = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/branches`, token, data);
   276	export const gitlabCreateMR = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/merge-request`, token, data);
   277	export const gitlabAnalyzeRepo = (token, repoId) => request("POST", `/gitlab/repos/${repoId}/analyze`, token);
   278	export const gitlabGetRepoMap = (token, repoId) => request("GET", `/gitlab/repos/${repoId}/map`, token);
   279	export const gitlabListActions = (token, status) => request("GET", `/gitlab/actions?status=${status || "pending"}`, token);
   280	export const gitlabCreateAction = (token, data) => request("POST", "/gitlab/actions", token, data);
   281	export const gitlabApproveAction = (token, actionId) => request("POST", `/gitlab/actions/${actionId}/approve`, token);
   282	export const gitlabRejectAction = (token, actionId) => request("POST", `/gitlab/actions/${actionId}/reject`, token);│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [045]: frontend/src/api.js


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [046/69]: frontend/src/components/AttachmentPreview.jsx
│ LANGUAGE: jsx | LINES: 158 | SIZE: 4375 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	import React, { useMemo } from "react";
     2	import {
     3	  X,
     4	  FileText,
     5	  Image as ImageIcon,
     6	  Film,
     7	  File,
     8	  FileSpreadsheet,
     9	} from "lucide-react";
    10	
    11	/**
    12	 * Renders a preview chip for an attached file.
    13	 * Used both in the pending-files area (before send) and in message bubbles (after send).
    14	 *
    15	 * Props:
    16	 *   file?: File object (for pending uploads — generates preview from File API)
    17	 *   attachment?: { filename, mime_type, file_size, media_type, preview_url } (for sent messages)
    18	 *   onRemove?: () => void (if removable)
    19	 *   isPending?: boolean (styling difference)
    20	 */
    21	export default function AttachmentPreview({
    22	  file,
    23	  attachment,
    24	  onRemove,
    25	  isPending,
    26	}) {
    27	  const info = useMemo(() => {
    28	    if (file) {
    29	      return {
    30	        name: file.name,
    31	        size: file.size,
    32	        type: file.type,
    33	        mediaType: classifyMime(file.type),
    34	        previewUrl: file.type.startsWith("image/")
    35	          ? URL.createObjectURL(file)
    36	          : null,
    37	      };
    38	    }
    39	    if (attachment) {
    40	      return {
    41	        name: attachment.filename || attachment.original_filename || "file",
    42	        size: attachment.file_size,
    43	        type: attachment.mime_type,
    44	        mediaType: attachment.media_type,
    45	        previewUrl: attachment.preview_url || null,
    46	      };
    47	    }
    48	    return { name: "unknown", size: 0, type: "", mediaType: "unknown", previewUrl: null };
    49	  }, [file, attachment]);
    50	
    51	  const Icon = getIcon(info.mediaType);
    52	  const sizeStr = formatSize(info.size);
    53	
    54	  return (
    55	    <div
    56	      className={`
    57	      group relative flex items-center gap-2 rounded-lg border px-2.5 py-1.5 text-xs
    58	      ${
    59	        isPending
    60	          ? "bg-anton-card border-anton-accent/30 text-anton-text"
    61	          : "bg-anton-surface border-anton-border text-anton-muted"
    62	      }
    63	      transition hover:border-anton-accent/50
    64	    `}
    65	    >
    66	      {/* Image thumbnail */}
    67	      {info.previewUrl && info.mediaType === "image" ? (
    68	        <img
    69	          src={info.previewUrl}
    70	          alt={info.name}
    71	          className="w-8 h-8 rounded object-cover flex-shrink-0"
    72	          onLoad={() => {
    73	            // Revoke blob URL after load to free memory (only for pending files)
    74	            if (file && info.previewUrl) {
    75	              // Don't revoke immediately — component might re-render
    76	            }
    77	          }}
    78	        />
    79	      ) : (
    80	        <Icon
    81	          size={16}
    82	          className={`flex-shrink-0 ${
    83	            isPending ? "text-anton-accent" : "text-anton-muted"
    84	          }`}
    85	        />
    86	      )}
    87	
    88	      <div className="flex flex-col min-w-0">
    89	        <span className="truncate max-w-[140px] font-medium">{info.name}</span>
    90	        <span className="text-[10px] text-anton-muted">{sizeStr}</span>
    91	      </div>
    92	
    93	      {/* Remove button */}
    94	      {onRemove && (
    95	        <button
    96	          onClick={(e) => {
    97	            e.stopPropagation();
    98	            onRemove();
    99	          }}
   100	          className="ml-1 p-0.5 rounded-full text-anton-muted hover:text-anton-danger hover:bg-anton-danger/10 transition opacity-0 group-hover:opacity-100"
   101	          title="Remove"
   102	        >
   103	          <X size={12} />
   104	        </button>
   105	      )}
   106	
   107	      {/* Video badge */}
   108	      {info.mediaType === "video" && (
   109	        <span className="absolute -top-1 -right-1 bg-anton-accent text-white text-[9px] font-bold px-1 rounded">
   110	          VID
   111	        </span>
   112	      )}
   113	    </div>
   114	  );
   115	}
   116	
   117	function classifyMime(mime) {
   118	  if (!mime) return "unknown";
   119	  if (mime.startsWith("image/")) return "image";
   120	  if (mime.startsWith("video/")) return "video";
   121	  if (
   122	    mime === "application/pdf" ||
   123	    mime.includes("word") ||
   124	    mime.includes("document")
   125	  )
   126	    return "document";
   127	  if (
   128	    mime.includes("excel") ||
   129	    mime.includes("spreadsheet") ||
   130	    mime === "text/csv"
   131	  )
   132	    return "spreadsheet";
   133	  if (mime.startsWith("text/")) return "text";
   134	  return "unknown";
   135	}
   136	
   137	function getIcon(mediaType) {
   138	  switch (mediaType) {
   139	    case "image":
   140	      return ImageIcon;
   141	    case "video":
   142	      return Film;
   143	    case "document":
   144	      return FileText;
   145	    case "spreadsheet":
   146	      return FileSpreadsheet;
   147	    case "text":
   148	      return FileText;
   149	    default:
   150	      return File;
   151	  }
   152	}
   153	
   154	function formatSize(bytes) {
   155	  if (!bytes || bytes === 0) return "0 B";
   156	  if (bytes < 1024) return `${bytes} B`;
   157	  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
   158	  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
   159	}│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [046]: frontend/src/components/AttachmentPreview.jsx


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [047/69]: frontend/src/components/ChatView.jsx
│ LANGUAGE: jsx | LINES: 252 | SIZE: 25760 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
     2	import { useApp, usePermissions } from "../store";
     3	import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments, gitlabListRepos, gitlabCommitSingle, refreshRepoContext, exportPptx, exportDocx } from "../api";
     4	import * as streamManager from "../streamManager";
     5	import MessageBubble from "./MessageBubble";
     6	import { Send, Square, Settings2, X, Brain, BookOpen, Paperclip, FileText, Loader2, Upload, Film, Image as ImageIcon, FileCode, GitBranch, RefreshCw, Globe, Presentation, FileOutput, Wand2, ChevronDown, Paintbrush } from "lucide-react";
     7	
     8	const ALL_MODELS = [
     9	  { id: "eu.anthropic.claude-opus-4-6-v1", label: "Opus 4.6" },
    10	  { id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Haiku 4.5" },
    11	];
    12	const TYPE_ICONS = { image: ImageIcon, video: Film, document: FileText, text: FileCode };
    13	const TYPE_COLORS = { image: "border-blue-500/40 bg-blue-500/10", video: "border-purple-500/40 bg-purple-500/10", document: "border-amber-500/40 bg-amber-500/10", text: "border-green-500/40 bg-green-500/10" };
    14	const TYPE_ICON_COLORS = { image: "text-blue-400", video: "text-purple-400", document: "text-amber-400", text: "text-green-400" };
    15	
    16	const UI_DESIGN_PREFIX = `[UI DESIGN MODE] You are now a world-class UI/UX designer. Generate COMPLETE, SELF-CONTAINED HTML files that render beautifully in a browser preview.\n\nCRITICAL RULES:\n- Output a SINGLE HTML file using the format \`\`\`html:design.html with ALL CSS and JS embedded inline\n- Include <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n- Use modern CSS (flexbox, grid, custom properties, transitions, animations)\n- Make it fully responsive (mobile-first)\n- Use realistic, professional content (NOT \"Lorem ipsum\")\n- Include hover states, focus states, and micro-interactions\n- Use a cohesive color palette and typography (Google Fonts via CDN OK)\n- If the design uses Tailwind-like utilities, include the Tailwind CDN script\n- For multi-screen apps, generate separate HTML files per screen\n- The output MUST look production-quality and polished\n- Include subtle shadows, gradients, and modern design patterns\n\n`;
    17	
    18	function classifyFile(f) { const ext = (f.name || "").split(".").pop().toLowerCase(); const mime = f.type || ""; if (mime.startsWith("image/") || ["jpg", "jpeg", "png", "gif", "webp", "bmp"].includes(ext)) return "image"; if (mime.startsWith("video/") || ["mp4", "mov", "avi", "mkv", "webm"].includes(ext)) return "video"; if (mime === "application/pdf" || ext === "pdf") return "document"; return "text"; }
    19	function fmtSize(b) { if (!b) return "0B"; if (b < 1024) return b + "B"; if (b < 1048576) return (b / 1024).toFixed(0) + "KB"; return (b / 1048576).toFixed(1) + "MB"; }
    20	
    21	export default function ChatView({ chatId }) {
    22	  const { state, dispatch } = useApp();
    23	  const perms = usePermissions();
    24	  const currentChat = state.chats.find(c => c.id === chatId);
    25	  const messages = state.chatMessages[chatId] || [];
    26	  const isSuperadmin = state.user?.role === "superadmin";
    27	
    28	  // Filter models based on permissions
    29	  const MODELS = useMemo(() => {
    30	    const allowed = perms.allowed_models;
    31	    if (!allowed || allowed === "all") return ALL_MODELS;
    32	    const list = allowed.split(",").map(s => s.trim());
    33	    const filtered = ALL_MODELS.filter(m => list.includes(m.id));
    34	    return filtered.length > 0 ? filtered : ALL_MODELS;
    35	  }, [perms.allowed_models]);
    36	
    37	  const [input, setInput] = useState("");
    38	  const [showSettings, setShowSettings] = useState(false);
    39	  const [showTools, setShowTools] = useState(false);
    40	  const [model, setModel] = useState(currentChat?.model || MODELS[0]?.id);
    41	  const [maxTokens, setMaxTokens] = useState(Math.min(currentChat?.max_tokens || 4096, perms.max_tokens_cap || 4096));
    42	  const [reasoningBudget, setReasoningBudget] = useState(Math.min(currentChat?.reasoning_budget ?? 0, perms.max_reasoning_budget || 0));
    43	  const [selectedKbId, setSelectedKbId] = useState(currentChat?.knowledge_base_id || null);
    44	  const [selectedRepoId, setSelectedRepoId] = useState(currentChat?.linked_repo_id || null);
    45	  const [webSearch, setWebSearch] = useState(false);
    46	  const [uiDesign, setUiDesign] = useState(false);
    47	  const [kbs, setKbs] = useState([]);
    48	  const [repos, setRepos] = useState([]);
    49	  const [pendingFiles, setPendingFiles] = useState([]);
    50	  const [uploading, setUploading] = useState(false);
    51	  const [dragOver, setDragOver] = useState(false);
    52	  const [exporting, setExporting] = useState("");
    53	  const [streamData, setStreamData] = useState(streamManager.getStreamData(chatId));
    54	
    55	  const scrollRef = useRef(null); const inputRef = useRef(null); const fileRef = useRef(null); const autoScroll = useRef(true); const rafRef = useRef(null);
    56	
    57	  const maxTokensCap = perms.max_tokens_cap || 4096;
    58	  const maxReasoningCap = perms.max_reasoning_budget || 0;
    59	
    60	  useEffect(() => { setStreamData(streamManager.getStreamData(chatId)); return streamManager.subscribe(chatId, () => setStreamData(streamManager.getStreamData(chatId))); }, [chatId]);
    61	  const scrollBottom = useCallback(() => { if (!autoScroll.current || rafRef.current) return; rafRef.current = requestAnimationFrame(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); rafRef.current = null; }); }, []);
    62	
    63	  useEffect(() => { (async () => { try { const [msgs, kbData] = await Promise.all([getMessages(state.token, chatId), perms.can_use_knowledge_base ? listKnowledgeBases(state.token) : []]); dispatch({ type: "SET_MESSAGES", chatId, messages: msgs }); setKbs(kbData); if (isSuperadmin || perms.can_use_gitlab) { try { setRepos(await gitlabListRepos(state.token)); } catch { } } } catch { } })(); }, [chatId, state.token, dispatch]);
    64	  useEffect(scrollBottom, [messages, streamData.text, streamData.thinking, scrollBottom]);
    65	  useEffect(() => { inputRef.current?.focus(); }, [chatId]);
    66	  useEffect(() => { if (currentChat) { const m = currentChat.model || MODELS[0]?.id; setModel(MODELS.find(x => x.id === m) ? m : MODELS[0]?.id); setMaxTokens(Math.min(currentChat.max_tokens || 4096, maxTokensCap)); setReasoningBudget(Math.min(currentChat.reasoning_budget ?? 0, maxReasoningCap)); setSelectedKbId(currentChat.knowledge_base_id || null); setSelectedRepoId(currentChat.linked_repo_id || null); } }, [chatId]);
    67	
    68	  const onScroll = useCallback(() => { const el = scrollRef.current; if (el) autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200; }, []);
    69	  const saveSettings = useCallback(async () => { try { await updateChat(state.token, chatId, { model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId || "", linked_repo_id: selectedRepoId || "" }); const repoObj = selectedRepoId ? repos.find(r => r.id === selectedRepoId) || null : null; dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, linked_repo_id: selectedRepoId, linked_repo: repoObj } }); } catch { } }, [state.token, chatId, model, maxTokens, reasoningBudget, selectedKbId, selectedRepoId, repos, dispatch]);
    70	
    71	  function toggleSettings() { if (showSettings) saveSettings(); setShowSettings(!showSettings); setShowTools(false); }
    72	  function addFiles(files) { setPendingFiles(prev => [...prev, ...files.map(f => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]); }
    73	  function removePending(i) { setPendingFiles(prev => { if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview); return prev.filter((_, j) => j !== i); }); }
    74	
    75	  const handleSend = useCallback(async () => {
    76	    const raw = input.trim(); if ((!raw && !pendingFiles.length) || streamData.streaming) return;
    77	    const text = raw || "Please analyze the attached file(s).";
    78	    const content = uiDesign ? UI_DESIGN_PREFIX + text : text;
    79	
    80	    let attIds = [], uploaded = [];
    81	    if (pendingFiles.length) { setUploading(true); try { const res = await uploadAttachments(state.token, chatId, pendingFiles.map(p => p.file)); uploaded = (res.attachments || []).filter(a => !a.error); attIds = uploaded.map(a => a.id); } catch (e) { setUploading(false); alert(e.message); return; } setUploading(false); }
    82	    dispatch({ type: "ADD_MESSAGE", chatId, message: { id: `tmp-${Date.now()}`, role: "user", content: text, created_at: new Date().toISOString(), attachments: uploaded } });
    83	    setInput(""); pendingFiles.forEach(p => { if (p.preview) URL.revokeObjectURL(p.preview); }); setPendingFiles([]); autoScroll.current = true;
    84	    if (inputRef.current) inputRef.current.style.height = "auto";
    85	    streamManager.startStream({ token: state.token, chatId, body: { content, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, attachment_ids: attIds, web_search: webSearch } });
    86	  }, [input, pendingFiles, streamData.streaming, state.token, chatId, model, maxTokens, reasoningBudget, selectedKbId, webSearch, uiDesign, dispatch]);
    87	
    88	  function handleKeyDown(e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }
    89	  function handlePaste(e) { const items = Array.from(e.clipboardData?.items || []).filter(i => i.kind === "file"); if (!items.length) return; e.preventDefault(); addFiles(items.map(i => i.getAsFile()).filter(Boolean)); }
    90	  function handleDrop(e) { e.preventDefault(); setDragOver(false); const files = Array.from(e.dataTransfer?.files || []); if (files.length) addFiles(files); }
    91	
    92	  const lastAssistantContent = messages.filter(m => m.role === "assistant").pop()?.content;
    93	
    94	  async function handleExport(type) {
    95	    if (!lastAssistantContent) return;
    96	    setExporting(type); setShowTools(false);
    97	    try {
    98	      if (type === "pptx") await exportPptx(state.token, lastAssistantContent, currentChat?.title);
    99	      else if (type === "docx") await exportDocx(state.token, lastAssistantContent, currentChat?.title);
   100	    } catch (e) { alert(`Export failed: ${e.message}`); }
   101	    setExporting("");
   102	  }
   103	
   104	  const streaming = streamData.streaming;
   105	  const linkedRepo = currentChat?.linked_repo;
   106	
   107	  const handleCommitFromChat = useCallback(async (filePath, code, action) => {
   108	    if (!linkedRepo) return;
   109	    const msg = prompt("Commit message:", `${action === "create" ? "Create" : "Update"} ${filePath} via Son of Anton`);
   110	    if (!msg) return;
   111	    try { await gitlabCommitSingle(state.token, linkedRepo.id, { branch: linkedRepo.default_branch, file_path: filePath, content: code, commit_message: msg, action }); try { await refreshRepoContext(state.token, chatId); } catch { } } catch (e) { alert(`❌ ${e.message}`); throw e; }
   112	  }, [linkedRepo, state.token, chatId]);
   113	
   114	  const canAttach = perms.can_use_attachments;
   115	  const canWebSearch = perms.can_use_web_search;
   116	  const canUiDesign = perms.can_use_ui_design;
   117	  const canExportPptx = perms.can_export_pptx;
   118	  const canExportDocx = perms.can_export_docx;
   119	  const canKB = perms.can_use_knowledge_base;
   120	  const canGitlab = perms.can_use_gitlab || isSuperadmin;
   121	  const hasAnyTool = canWebSearch || canUiDesign || canExportPptx || canExportDocx;
   122	
   123	  return (
   124	    <div className="flex-1 flex flex-col min-h-0 relative" onDrop={handleDrop} onDragOver={e => { e.preventDefault(); setDragOver(true); }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOver(false); }}>
   125	      {dragOver && canAttach && <div className="absolute inset-0 z-40 bg-anton-accent/10 backdrop-blur-sm border-2 border-dashed border-anton-accent rounded-lg flex items-center justify-center pointer-events-none"><div className="text-center"><Upload size={36} className="text-anton-accent mx-auto mb-2 animate-bounce" /><p className="text-white font-semibold text-sm">Drop files here</p></div></div>}
   126	
   127	      {linkedRepo && (
   128	        <div className="px-3 py-1.5 bg-orange-500/10 border-b border-orange-500/20 flex items-center gap-2 text-xs flex-wrap">
   129	          <GitBranch size={12} className="text-orange-400" /><span className="text-orange-300 font-medium">{linkedRepo.name}</span><span className="text-orange-300/60">({linkedRepo.default_branch})</span>
   130	        </div>
   131	      )}
   132	
   133	      {uiDesign && (
   134	        <div className="px-3 py-1.5 bg-blue-500/10 border-b border-blue-500/20 flex items-center gap-2 text-xs">
   135	          <Paintbrush size={12} className="text-blue-400" /><span className="text-blue-300 font-medium">UI Design Mode</span><span className="text-blue-300/60">— Generating previewable HTML designs</span>
   136	          <button onClick={() => setUiDesign(false)} className="ml-auto text-blue-400/60 hover:text-blue-300 transition"><X size={12} /></button>
   137	        </div>
   138	      )}
   139	
   140	      <div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto overscroll-contain px-3 sm:px-4 py-3 sm:py-4 space-y-3">
   141	        {messages.map(m => <MessageBubble key={m.id} message={m} token={state.token} linkedRepo={linkedRepo} onCommit={handleCommitFromChat} chatId={chatId} />)}
   142	        {streaming && (streamData.thinking || streamData.text) && <MessageBubble message={{ id: "streaming", role: "assistant", content: streamData.text, thinking_content: streamData.thinking || null, attachments: [] }} isStreaming isThinking={streamData.isThinking} token={state.token} />}
   143	        {streaming && !streamData.text && !streamData.thinking && (
   144	          <div className="flex items-center gap-2 px-3 py-3 animate-fade-in">
   145	            <div className="flex gap-1">{[0, 150, 300].map(d => <span key={d} className="w-1.5 h-1.5 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: d + "ms" }} />)}</div>
   146	            <span className="text-anton-muted text-sm">{uiDesign ? "Designing UI…" : webSearch ? "Searching web & thinking…" : linkedRepo ? "Loading codebase & thinking…" : "Thinking…"}</span>
   147	          </div>
   148	        )}
   149	      </div>
   150	
   151	      <div className="border-t border-anton-border bg-anton-surface px-3 pt-2 pb-2 sm:px-4 sm:pt-3 sm:pb-3 safe-bottom">
   152	        {showSettings && (
   153	          <div className="mb-2 bg-anton-card border border-anton-border rounded-xl p-3 space-y-3 animate-fade-in max-h-[50vh] overflow-y-auto">
   154	            <div className="flex items-center justify-between"><h3 className="text-sm font-semibold text-white flex items-center gap-1.5"><Settings2 size={14} className="text-anton-accent" /> Settings</h3><button onClick={toggleSettings} className="p-1 text-anton-muted hover:text-white"><X size={14} /></button></div>
   155	            <div><label className="text-xs text-anton-muted mb-1 block">Model</label><select value={model} onChange={e => setModel(e.target.value)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent">{MODELS.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}</select></div>
   156	            <div><div className="flex justify-between text-xs mb-1.5"><span className="text-anton-muted">Max Tokens</span><span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}{maxTokensCap < 65536 && <span className="text-anton-muted"> / {maxTokensCap.toLocaleString()}</span>}</span></div><input type="range" min={256} max={maxTokensCap} step={256} value={maxTokens} onChange={e => setMaxTokens(Number(e.target.value))} /></div>
   157	            {maxReasoningCap > 0 && (
   158	              <div><div className="flex justify-between text-xs mb-1.5"><span className="text-anton-muted flex items-center gap-1"><Brain size={12} className="text-purple-400" /> Reasoning</span><span className="text-purple-400 font-mono">{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}{maxReasoningCap < 32000 && <span className="text-anton-muted"> / {maxReasoningCap.toLocaleString()}</span>}</span></div><input type="range" min={0} max={maxReasoningCap} step={500} value={reasoningBudget} onChange={e => setReasoningBudget(Number(e.target.value))} /></div>
   159	            )}
   160	            {canKB && (
   161	              <div><label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><BookOpen size={12} /> Knowledge Base</label><select value={selectedKbId || ""} onChange={e => setSelectedKbId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent"><option value="">None</option>{kbs.map(kb => <option key={kb.id} value={kb.id}>{kb.name} ({kb.document_count} docs)</option>)}</select></div>
   162	            )}
   163	            {canGitlab && repos.length > 0 && (
   164	              <div><label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><GitBranch size={12} className="text-orange-400" /> Repository</label><select value={selectedRepoId || ""} onChange={e => setSelectedRepoId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-orange-400"><option value="">None</option>{repos.map(r => <option key={r.id} value={r.id}>🔀 {r.name}</option>)}</select></div>
   165	            )}
   166	          </div>
   167	        )}
   168	
   169	        {pendingFiles.length > 0 && (
   170	          <div className="mb-2 flex flex-wrap gap-1.5 animate-fade-in">
   171	            {pendingFiles.map((pf, i) => {
   172	              const Icon = TYPE_ICONS[pf.type] || FileText; return (
   173	                <div key={i} className={`relative group rounded-lg overflow-hidden border ${TYPE_COLORS[pf.type] || "border-anton-border bg-anton-card"}`}>
   174	                  {pf.type === "image" && pf.preview ? <img src={pf.preview} alt="" className="w-14 h-14 sm:w-16 sm:h-16 object-cover" loading="lazy" /> : (<div className="w-14 h-14 sm:w-16 sm:h-16 flex flex-col items-center justify-center px-1"><Icon size={16} className={`${TYPE_ICON_COLORS[pf.type] || "text-anton-muted"} mb-0.5`} /><span className="text-[7px] text-anton-muted text-center truncate w-full">{pf.file.name.slice(0, 8)}</span></div>)}
   175	                  <button onClick={() => removePending(i)} className="absolute -top-0.5 -right-0.5 w-5 h-5 bg-red-600 rounded-full flex items-center justify-center text-white shadow transition-opacity sm:opacity-0 sm:group-hover:opacity-100"><X size={10} /></button>
   176	                  <div className="absolute bottom-0 left-0 right-0 bg-black/70 text-[7px] text-white text-center py-px">{fmtSize(pf.file.size)}</div>
   177	                </div>
   178	              );
   179	            })}
   180	          </div>
   181	        )}
   182	
   183	        <div className="flex items-end gap-1.5">
   184	          <button onClick={toggleSettings} className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"}`}><Settings2 size={18} /></button>
   185	
   186	          {hasAnyTool && (
   187	            <div className="relative">
   188	              <button onClick={() => { setShowTools(!showTools); setShowSettings(false); }} className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${showTools ? "bg-blue-500/20 text-blue-400" : (webSearch || uiDesign) ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`} title="Tools"><Wand2 size={18} /></button>
   189	              {showTools && (
   190	                <div className="absolute bottom-full left-0 mb-2 w-56 bg-anton-card border border-anton-border rounded-xl shadow-2xl p-2 space-y-1 animate-fade-in z-30">
   191	                  <p className="text-[10px] text-anton-muted px-2 py-1 uppercase tracking-wider font-semibold">Tools</p>
   192	                  {canUiDesign && (
   193	                    <button onClick={() => setUiDesign(!uiDesign)} className={`w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-lg text-sm transition ${uiDesign ? "bg-blue-500/15 text-blue-400" : "text-white hover:bg-anton-bg"}`}>
   194	                      <span className="flex items-center gap-2"><Paintbrush size={15} /> UI Design</span>
   195	                      <div className={`w-8 h-4.5 rounded-full transition-colors ${uiDesign ? "bg-blue-500" : "bg-anton-border"}`}><div className={`w-3.5 h-3.5 rounded-full bg-white shadow transform transition-transform mt-0.5 ${uiDesign ? "translate-x-4 ml-0.5" : "translate-x-0.5"}`} /></div>
   196	                    </button>
   197	                  )}
   198	                  {canWebSearch && (
   199	                    <button onClick={() => setWebSearch(!webSearch)} className={`w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-lg text-sm transition ${webSearch ? "bg-green-500/15 text-green-400" : "text-white hover:bg-anton-bg"}`}>
   200	                      <span className="flex items-center gap-2"><Globe size={15} /> Web Search</span>
   201	                      <div className={`w-8 h-4.5 rounded-full transition-colors ${webSearch ? "bg-green-500" : "bg-anton-border"}`}><div className={`w-3.5 h-3.5 rounded-full bg-white shadow transform transition-transform mt-0.5 ${webSearch ? "translate-x-4 ml-0.5" : "translate-x-0.5"}`} /></div>
   202	                    </button>
   203	                  )}
   204	                  {(canExportPptx || canExportDocx) && <hr className="border-anton-border" />}
   205	                  {canExportPptx && (
   206	                    <button onClick={() => handleExport("pptx")} disabled={!lastAssistantContent || !!exporting} className="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg text-sm text-white hover:bg-anton-bg transition disabled:opacity-30 disabled:cursor-not-allowed">
   207	                      {exporting === "pptx" ? <Loader2 size={15} className="animate-spin" /> : <Presentation size={15} className="text-orange-400" />} Download PPTX
   208	                    </button>
   209	                  )}
   210	                  {canExportDocx && (
   211	                    <button onClick={() => handleExport("docx")} disabled={!lastAssistantContent || !!exporting} className="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg text-sm text-white hover:bg-anton-bg transition disabled:opacity-30 disabled:cursor-not-allowed">
   212	                      {exporting === "docx" ? <Loader2 size={15} className="animate-spin" /> : <FileOutput size={15} className="text-blue-400" />} Download DOCX
   213	                    </button>
   214	                  )}
   215	                </div>
   216	              )}
   217	            </div>
   218	          )}
   219	
   220	          {canAttach && (
   221	            <>
   222	              <button onClick={() => fileRef.current?.click()} className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`} title="Attach files"><Paperclip size={18} /></button>
   223	              <input ref={fileRef} type="file" multiple className="hidden" accept="image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.gd,.dart,.vue,.svelte,.log" onChange={e => { addFiles(Array.from(e.target.files || [])); e.target.value = ""; }} />
   224	            </>
   225	          )}
   226	
   227	          <div className="flex-1 min-w-0">
   228	            <textarea ref={inputRef} value={input} onChange={e => setInput(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste} placeholder={uiDesign ? "Describe a UI design…" : webSearch ? "Search the web & ask…" : pendingFiles.length ? "Add a message…" : linkedRepo ? `Ask about ${linkedRepo.name}…` : "Ask anything…"} rows={1} style={{ maxHeight: "120px" }} className="w-full bg-anton-card border border-anton-border rounded-xl px-3 py-2.5 text-white resize-none focus:outline-none focus:border-anton-accent transition leading-snug" onInput={e => { e.target.style.height = "auto"; e.target.style.height = Math.min(e.target.scrollHeight, 120) + "px"; }} />
   229	          </div>
   230	          {streaming ? (
   231	            <button onClick={() => streamManager.abortStream(chatId)} className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center"><Square size={18} /></button>
   232	          ) : (
   233	            <button onClick={handleSend} disabled={(!input.trim() && !pendingFiles.length) || uploading} className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center disabled:opacity-30">
   234	              {uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
   235	            </button>
   236	          )}
   237	        </div>
   238	
   239	        <div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-anton-muted flex-wrap">
   240	          <span>{MODELS.find(m => m.id === model)?.label}</span>
   241	          <span>•</span><span>{maxTokens.toLocaleString()} tok</span>
   242	          {reasoningBudget > 0 && <><span>•</span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>}
   243	          {uiDesign && <><span>•</span><span className="text-blue-400">🎨 UI Design</span></>}
   244	          {webSearch && <><span>•</span><span className="text-green-400">🌐 Web</span></>}
   245	          {selectedKbId && <><span>•</span><span className="text-green-400">📚 RAG</span></>}
   246	          {linkedRepo && <><span>•</span><span className="text-orange-400">🔀 {linkedRepo.name}</span></>}
   247	          {pendingFiles.length > 0 && <><span>•</span><span className="text-blue-400">📎 {pendingFiles.length}</span></>}
   248	          {messages.some(m => m.role === "assistant") && <button onClick={async () => { const all = messages.filter(m => m.role === "assistant").map(m => m.content).join("\n\n---\n\n"); if (all) try { await downloadZip(state.token, all, currentChat?.title); } catch { } }} className="ml-auto hover:text-anton-accent transition">⬇ Code</button>}
   249	        </div>
   250	      </div>
   251	    </div>
   252	  );
   253	}│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [047]: frontend/src/components/ChatView.jsx


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [048/69]: frontend/src/components/CodeBlock.jsx
│ LANGUAGE: jsx | LINES: 145 | SIZE: 6477 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	import React, { useState, useCallback, useMemo } from "react";
     2	import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
     3	import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
     4	import { Copy, Check, Download, GitCommitVertical, Loader2, Plus, Pencil, Eye } from "lucide-react";
     5	import UIPreview, { buildPreviewHTML } from "./UIPreview";
     6	
     7	const PREVIEWABLE_LANGS = new Set(["html", "htm", "jsx", "tsx", "vue", "svelte"]);
     8	const PREVIEWABLE_EXTS = /\.(html?|jsx|tsx|vue|svelte)$/i;
     9	
    10	export default React.memo(function CodeBlock({ language, filename, code, linkedRepo, onCommit }) {
    11	  const [copied, setCopied] = useState(false);
    12	  const [committing, setCommitting] = useState(false);
    13	  const [commitDone, setCommitDone] = useState(false);
    14	  const [showCommitOptions, setShowCommitOptions] = useState(false);
    15	  const [showPreview, setShowPreview] = useState(false);
    16	
    17	  const canPreview = useMemo(() => {
    18	    if (PREVIEWABLE_LANGS.has(language)) return true;
    19	    if (filename && PREVIEWABLE_EXTS.test(filename)) return true;
    20	    // Check if code looks like HTML
    21	    if (code.trim().match(/^<!DOCTYPE|^<html|^<div|^<section|^<main|^<header|^<template/i)) return true;
    22	    return false;
    23	  }, [language, filename, code]);
    24	
    25	  const previewHTML = useMemo(() => {
    26	    if (!canPreview) return null;
    27	    return buildPreviewHTML([{ language, filename, code }]);
    28	  }, [canPreview, language, filename, code]);
    29	
    30	  const handleCopy = useCallback(() => {
    31	    navigator.clipboard.writeText(code);
    32	    setCopied(true);
    33	    setTimeout(() => setCopied(false), 2000);
    34	  }, [code]);
    35	
    36	  const handleDownload = useCallback(() => {
    37	    const blob = new Blob([code], { type: "text/plain" });
    38	    const url = URL.createObjectURL(blob);
    39	    const a = document.createElement("a");
    40	    a.href = url;
    41	    a.download = filename || `code.${language || "txt"}`;
    42	    a.click();
    43	    URL.revokeObjectURL(url);
    44	  }, [code, filename, language]);
    45	
    46	  async function handleCommit(action) {
    47	    if (!onCommit || !filename) return;
    48	    setCommitting(true);
    49	    setShowCommitOptions(false);
    50	    try {
    51	      await onCommit(filename, code, action);
    52	      setCommitDone(true);
    53	      setTimeout(() => setCommitDone(false), 3000);
    54	    } catch { /* error handled in parent */ }
    55	    setCommitting(false);
    56	  }
    57	
    58	  const showGit = linkedRepo && filename;
    59	  const lineCount = code.split("\n").length;
    60	
    61	  return (
    62	    <>
    63	      <div className="my-3 rounded-xl overflow-hidden border border-anton-border bg-[#1a1b26]">
    64	        {/* Header */}
    65	        <div className="flex items-center justify-between px-3 py-1.5 bg-anton-surface border-b border-anton-border gap-2">
    66	          <div className="flex items-center gap-2 min-w-0">
    67	            {language && <span className="text-[10px] text-anton-accent font-mono uppercase shrink-0">{language}</span>}
    68	            {filename && <span className="text-[10px] text-anton-muted truncate">{filename}</span>}
    69	          </div>
    70	          <div className="flex items-center gap-0.5 shrink-0">
    71	            {/* Preview button */}
    72	            {canPreview && (
    73	              <button
    74	                onClick={() => setShowPreview(true)}
    75	                className="flex items-center gap-1 px-2 py-1 text-[10px] text-blue-400 hover:bg-blue-400/10 rounded transition"
    76	                title="Preview in browser"
    77	              >
    78	                <Eye size={11} /> Preview
    79	              </button>
    80	            )}
    81	            {/* Git commit buttons */}
    82	            {showGit && !commitDone && (
    83	              <div className="relative">
    84	                {committing ? (
    85	                  <span className="flex items-center gap-1 px-2 py-1 text-[10px] text-orange-400">
    86	                    <Loader2 size={11} className="animate-spin" /> Committing…
    87	                  </span>
    88	                ) : (
    89	                  <button onClick={() => setShowCommitOptions(!showCommitOptions)}
    90	                    className="flex items-center gap-1 px-2 py-1 text-[10px] text-orange-400 hover:bg-orange-400/10 rounded transition"
    91	                    title={`Commit to ${linkedRepo.name}`}>
    92	                    <GitCommitVertical size={11} /> Commit
    93	                  </button>
    94	                )}
    95	                {showCommitOptions && (
    96	                  <div className="absolute right-0 top-full mt-1 z-20 bg-anton-card border border-anton-border rounded-lg shadow-xl p-1.5 min-w-[140px] animate-fade-in">
    97	                    <button onClick={() => handleCommit("update")}
    98	                      className="w-full flex items-center gap-2 px-2.5 py-1.5 text-[11px] text-white hover:bg-anton-accent/10 rounded transition">
    99	                      <Pencil size={11} className="text-blue-400" /> Update file
   100	                    </button>
   101	                    <button onClick={() => handleCommit("create")}
   102	                      className="w-full flex items-center gap-2 px-2.5 py-1.5 text-[11px] text-white hover:bg-anton-accent/10 rounded transition">
   103	                      <Plus size={11} className="text-green-400" /> Create new
   104	                    </button>
   105	                  </div>
   106	                )}
   107	              </div>
   108	            )}
   109	            {commitDone && (
   110	              <span className="flex items-center gap-1 px-2 py-1 text-[10px] text-green-400">
   111	                <Check size={11} /> Committed!
   112	              </span>
   113	            )}
   114	            <button onClick={handleDownload} className="p-1.5 text-anton-muted hover:text-white transition" title="Download">
   115	              <Download size={12} />
   116	            </button>
   117	            <button onClick={handleCopy} className="p-1.5 text-anton-muted hover:text-white transition" title="Copy">
   118	              {copied ? <Check size={12} className="text-green-400" /> : <Copy size={12} />}
   119	            </button>
   120	          </div>
   121	        </div>
   122	
   123	        {/* Code */}
   124	        <SyntaxHighlighter
   125	          language={language || "text"}
   126	          style={oneDark}
   127	          customStyle={{ margin: 0, padding: "12px 16px", fontSize: "12px", lineHeight: "1.5", background: "transparent" }}
   128	          showLineNumbers={lineCount > 3}
   129	          lineNumberStyle={{ color: "#555", fontSize: "10px", paddingRight: "12px" }}
   130	          wrapLongLines
   131	        >
   132	          {code}
   133	        </SyntaxHighlighter>
   134	      </div>
   135	
   136	      {/* Preview Modal */}
   137	      {showPreview && previewHTML && (
   138	        <UIPreview
   139	          html={previewHTML}
   140	          title={filename || `${language} preview`}
   141	          onClose={() => setShowPreview(false)}
   142	        />
   143	      )}
   144	    </>
   145	  );
   146	});│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [048]: frontend/src/components/CodeBlock.jsx


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [049/69]: frontend/src/components/ColorPalette.jsx
│ LANGUAGE: jsx | LINES: 53 | SIZE: 2375 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	import React, { useState } from "react";
     2	import { Copy, Check, Palette } from "lucide-react";
     3	
     4	export default function ColorPalette({ colors, title }) {
     5	  const [copiedColor, setCopiedColor] = useState(null);
     6	
     7	  function copyColor(color) {
     8	    navigator.clipboard.writeText(color);
     9	    setCopiedColor(color);
    10	    setTimeout(() => setCopiedColor(null), 1500);
    11	  }
    12	
    13	  function getContrastColor(hex) {
    14	    const c = hex.replace("#", "");
    15	    const r = parseInt(c.substr(0, 2), 16);
    16	    const g = parseInt(c.substr(2, 2), 16);
    17	    const b = parseInt(c.substr(4, 2), 16);
    18	    const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
    19	    return luminance > 0.5 ? "#000000" : "#FFFFFF";
    20	  }
    21	
    22	  if (!colors?.length) return null;
    23	
    24	  return (
    25	    <div className="rounded-xl border border-anton-border overflow-hidden bg-anton-card">
    26	      <div className="flex items-center gap-2 px-3 py-2 bg-anton-surface border-b border-anton-border">
    27	        <Palette size={14} className="text-pink-400" />
    28	        <span className="text-xs font-medium text-white">{title || "Color Palette"}</span>
    29	      </div>
    30	      <div className="p-3 flex flex-wrap gap-2">
    31	        {colors.map((color, i) => {
    32	          const hex = typeof color === "string" ? color : color.hex;
    33	          const name = typeof color === "string" ? null : color.name;
    34	          const contrast = getContrastColor(hex);
    35	          const isCopied = copiedColor === hex;
    36	
    37	          return (
    38	            <button key={i} onClick={() => copyColor(hex)}
    39	              className="group relative rounded-lg overflow-hidden transition-transform hover:scale-105 active:scale-95"
    40	              style={{ width: "80px", height: "80px", backgroundColor: hex }}>
    41	              <div className="absolute inset-0 flex flex-col items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/30">
    42	                {isCopied ? <Check size={16} style={{ color: contrast }} /> : <Copy size={16} style={{ color: contrast }} />}
    43	              </div>
    44	              <div className="absolute bottom-0 left-0 right-0 px-1 py-0.5 text-center" style={{ color: contrast }}>
    45	                {name && <div className="text-[8px] font-medium truncate">{name}</div>}
    46	                <div className="text-[9px] font-mono opacity-80">{hex}</div>
    47	              </div>
    48	            </button>
    49	          );
    50	        })}
    51	      </div>
    52	    </div>
    53	  );
    54	}│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [049]: frontend/src/components/ColorPalette.jsx


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [050/69]: frontend/src/components/FileUploadButton.jsx
│ LANGUAGE: jsx | LINES: 81 | SIZE: 1777 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	import React, { useRef } from "react";
     2	import { Paperclip } from "lucide-react";
     3	
     4	const ACCEPT = [
     5	  // Images
     6	  "image/jpeg",
     7	  "image/png",
     8	  "image/gif",
     9	  "image/webp",
    10	  // Videos
    11	  "video/mp4",
    12	  "video/webm",
    13	  "video/quicktime",
    14	  "video/mpeg",
    15	  "video/x-matroska",
    16	  // Documents
    17	  "application/pdf",
    18	  "text/csv",
    19	  "application/msword",
    20	  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
    21	  "application/vnd.ms-excel",
    22	  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    23	  "text/html",
    24	  "text/plain",
    25	  "text/markdown",
    26	  // Also accept by extension for browsers that are bad at MIME
    27	  ".jpg",
    28	  ".jpeg",
    29	  ".png",
    30	  ".gif",
    31	  ".webp",
    32	  ".mp4",
    33	  ".webm",
    34	  ".mov",
    35	  ".mkv",
    36	  ".pdf",
    37	  ".csv",
    38	  ".doc",
    39	  ".docx",
    40	  ".xls",
    41	  ".xlsx",
    42	  ".html",
    43	  ".txt",
    44	  ".md",
    45	].join(",");
    46	
    47	export default function FileUploadButton({ onFilesSelected }) {
    48	  const inputRef = useRef(null);
    49	
    50	  function handleClick() {
    51	    inputRef.current?.click();
    52	  }
    53	
    54	  function handleChange(e) {
    55	    const files = e.target.files;
    56	    if (files && files.length > 0) {
    57	      onFilesSelected(files);
    58	    }
    59	    // Reset so the same file can be re-selected
    60	    e.target.value = "";
    61	  }
    62	
    63	  return (
    64	    <>
    65	      <input
    66	        ref={inputRef}
    67	        type="file"
    68	        multiple
    69	        accept={ACCEPT}
    70	        onChange={handleChange}
    71	        className="hidden"
    72	      />
    73	      <button
    74	        onClick={handleClick}
    75	        className="flex items-center justify-center w-10 h-10 rounded-xl border border-anton-border text-anton-muted hover:text-anton-accent hover:border-anton-accent/40 hover:bg-anton-accent/10 transition"
    76	        title="Attach files (images, videos, documents)"
    77	      >
    78	        <Paperclip size={16} />
    79	      </button>
    80	    </>
    81	  );
    82	}│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [050]: frontend/src/components/FileUploadButton.jsx


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [051/69]: frontend/src/components/LivePreview.jsx
│ LANGUAGE: jsx | LINES: 130 | SIZE: 5085 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	import React, { useState, useRef, useEffect } from "react";
     2	import { Eye, EyeOff, Smartphone, Monitor, Tablet, Maximize2, Minimize2, Code2, Copy, Check, ExternalLink } from "lucide-react";
     3	
     4	const VIEWPORT_SIZES = {
     5	  mobile: { width: 375, height: 667, label: "Mobile", icon: Smartphone },
     6	  tablet: { width: 768, height: 1024, label: "Tablet", icon: Tablet },
     7	  desktop: { width: "100%", height: 600, label: "Desktop", icon: Monitor },
     8	};
     9	
    10	export default function LivePreview({ html, css, js, title }) {
    11	  const [viewport, setViewport] = useState("desktop");
    12	  const [expanded, setExpanded] = useState(false);
    13	  const [showCode, setShowCode] = useState(false);
    14	  const [copied, setCopied] = useState(false);
    15	  const iframeRef = useRef(null);
    16	
    17	  const fullHtml = `
    18	<!DOCTYPE html>
    19	<html>
    20	<head>
    21	  <meta charset="UTF-8">
    22	  <meta name="viewport" content="width=device-width, initial-scale=1.0">
    23	  <script src="https://cdn.tailwindcss.com"><\/script>
    24	  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
    25	  <style>
    26	    * { margin: 0; padding: 0; box-sizing: border-box; }
    27	    body { font-family: 'Inter', system-ui, sans-serif; }
    28	    ${css || ""}
    29	  </style>
    30	</head>
    31	<body>
    32	  ${html || ""}
    33	  ${js ? `<script>${js}<\/script>` : ""}
    34	</body>
    35	</html>`;
    36	
    37	  useEffect(() => {
    38	    if (iframeRef.current) {
    39	      const doc = iframeRef.current.contentDocument;
    40	      if (doc) {
    41	        doc.open();
    42	        doc.write(fullHtml);
    43	        doc.close();
    44	      }
    45	    }
    46	  }, [fullHtml, viewport]);
    47	
    48	  const vp = VIEWPORT_SIZES[viewport];
    49	  const iframeWidth = vp.width === "100%" ? "100%" : `${vp.width}px`;
    50	
    51	  function handleCopy() {
    52	    navigator.clipboard.writeText(fullHtml);
    53	    setCopied(true);
    54	    setTimeout(() => setCopied(false), 2000);
    55	  }
    56	
    57	  function openInNewTab() {
    58	    const blob = new Blob([fullHtml], { type: "text/html" });
    59	    const url = URL.createObjectURL(blob);
    60	    window.open(url, "_blank");
    61	    setTimeout(() => URL.revokeObjectURL(url), 5000);
    62	  }
    63	
    64	  return (
    65	    <div className={`rounded-xl border border-anton-border overflow-hidden bg-anton-card ${expanded ? "fixed inset-4 z-50" : ""}`}>
    66	      {/* Toolbar */}
    67	      <div className="flex items-center justify-between px-3 py-2 bg-anton-surface border-b border-anton-border">
    68	        <div className="flex items-center gap-2">
    69	          <Eye size={14} className="text-green-400" />
    70	          <span className="text-xs font-medium text-white">{title || "Live Preview"}</span>
    71	        </div>
    72	
    73	        <div className="flex items-center gap-1">
    74	          {/* Viewport Switcher */}
    75	          {Object.entries(VIEWPORT_SIZES).map(([key, val]) => {
    76	            const Icon = val.icon;
    77	            return (
    78	              <button key={key} onClick={() => setViewport(key)}
    79	                className={`p-1.5 rounded transition ${viewport === key ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white"}`}
    80	                title={val.label}>
    81	                <Icon size={14} />
    82	              </button>
    83	            );
    84	          })}
    85	
    86	          <div className="w-px h-4 bg-anton-border mx-1" />
    87	
    88	          <button onClick={() => setShowCode(!showCode)}
    89	            className={`p-1.5 rounded transition ${showCode ? "bg-blue-500/20 text-blue-400" : "text-anton-muted hover:text-white"}`}
    90	            title="Toggle code">
    91	            <Code2 size={14} />
    92	          </button>
    93	
    94	          <button onClick={handleCopy} className="p-1.5 rounded text-anton-muted hover:text-white transition" title="Copy HTML">
    95	            {copied ? <Check size={14} className="text-green-400" /> : <Copy size={14} />}
    96	          </button>
    97	
    98	          <button onClick={openInNewTab} className="p-1.5 rounded text-anton-muted hover:text-white transition" title="Open in new tab">
    99	            <ExternalLink size={14} />
   100	          </button>
   101	
   102	          <button onClick={() => setExpanded(!expanded)}
   103	            className="p-1.5 rounded text-anton-muted hover:text-white transition"
   104	            title={expanded ? "Minimize" : "Fullscreen"}>
   105	            {expanded ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
   106	          </button>
   107	        </div>
   108	      </div>
   109	
   110	      {/* Preview Area */}
   111	      <div className="flex justify-center bg-[#1a1a2e] p-4 overflow-auto" style={{ minHeight: expanded ? "calc(100vh - 120px)" : "400px" }}>
   112	        <div style={{ width: iframeWidth, maxWidth: "100%" }} className="bg-white rounded-lg shadow-2xl overflow-hidden transition-all duration-300">
   113	          <iframe
   114	            ref={iframeRef}
   115	            title="preview"
   116	            sandbox="allow-scripts allow-same-origin"
   117	            className="w-full border-0"
   118	            style={{ height: expanded ? "calc(100vh - 160px)" : `${typeof vp.height === "number" ? vp.height : 500}px` }}
   119	          />
   120	        </div>
   121	      </div>
   122	
   123	      {/* Code Panel */}
   124	      {showCode && (
   125	        <div className="border-t border-anton-border bg-[#0d1117] p-3 max-h-60 overflow-auto">
   126	          <pre className="text-xs text-gray-300 font-mono whitespace-pre-wrap">{fullHtml}</pre>
   127	        </div>
   128	      )}
   129	    </div>
   130	  );
   131	}│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [051]: frontend/src/components/LivePreview.jsx


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [052/69]: frontend/src/components/MermaidDiagram.jsx
│ LANGUAGE: jsx | LINES: 116 | SIZE: 4131 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	import React, { useEffect, useRef, useState } from "react";
     2	import { GitBranch, Maximize2, Minimize2, Copy, Check } from "lucide-react";
     3	
     4	let mermaidInitialized = false;
     5	
     6	export default function MermaidDiagram({ code, title }) {
     7	  const containerRef = useRef(null);
     8	  const [svg, setSvg] = useState("");
     9	  const [error, setError] = useState(null);
    10	  const [expanded, setExpanded] = useState(false);
    11	  const [copied, setCopied] = useState(false);
    12	
    13	  useEffect(() => {
    14	    let cancelled = false;
    15	
    16	    async function render() {
    17	      try {
    18	        const mermaid = (await import("mermaid")).default;
    19	
    20	        if (!mermaidInitialized) {
    21	          mermaid.initialize({
    22	            startOnLoad: false,
    23	            theme: "dark",
    24	            themeVariables: {
    25	              primaryColor: "#e53e3e",
    26	              primaryTextColor: "#fff",
    27	              primaryBorderColor: "#e53e3e",
    28	              lineColor: "#6b6b8a",
    29	              secondaryColor: "#1a1a2e",
    30	              tertiaryColor: "#16162a",
    31	              background: "#0f0f1a",
    32	              mainBkg: "#1a1a2e",
    33	              nodeBorder: "#e53e3e",
    34	              clusterBkg: "#16162a",
    35	              titleColor: "#fff",
    36	              edgeLabelBackground: "#1a1a2e",
    37	            },
    38	            fontFamily: "Inter, system-ui, sans-serif",
    39	            fontSize: 14,
    40	          });
    41	          mermaidInitialized = true;
    42	        }
    43	
    44	        const id = `mermaid-${Date.now()}-${Math.random().toString(36).slice(2)}`;
    45	        const { svg: renderedSvg } = await mermaid.render(id, code.trim());
    46	
    47	        if (!cancelled) {
    48	          setSvg(renderedSvg);
    49	          setError(null);
    50	        }
    51	      } catch (e) {
    52	        if (!cancelled) {
    53	          setError(e.message || "Failed to render diagram");
    54	          setSvg("");
    55	        }
    56	      }
    57	    }
    58	
    59	    if (code?.trim()) render();
    60	    return () => { cancelled = true; };
    61	  }, [code]);
    62	
    63	  function handleCopy() {
    64	    navigator.clipboard.writeText(code);
    65	    setCopied(true);
    66	    setTimeout(() => setCopied(false), 2000);
    67	  }
    68	
    69	  if (error) {
    70	    return (
    71	      <div className="rounded-xl border border-red-500/30 bg-red-500/5 p-4">
    72	        <div className="flex items-center gap-2 text-red-400 text-xs mb-2">
    73	          <GitBranch size={14} />
    74	          <span>Diagram Error</span>
    75	        </div>
    76	        <pre className="text-xs text-red-300/70 font-mono">{error}</pre>
    77	        <pre className="text-xs text-anton-muted font-mono mt-2 bg-anton-bg p-2 rounded">{code}</pre>
    78	      </div>
    79	    );
    80	  }
    81	
    82	  if (!svg) {
    83	    return (
    84	      <div className="rounded-xl border border-anton-border bg-anton-card p-8 flex items-center justify-center">
    85	        <div className="flex items-center gap-2 text-anton-muted text-sm">
    86	          <div className="w-4 h-4 border-2 border-anton-accent border-t-transparent rounded-full animate-spin" />
    87	          Rendering diagram...
    88	        </div>
    89	      </div>
    90	    );
    91	  }
    92	
    93	  return (
    94	    <div className={`rounded-xl border border-anton-border overflow-hidden bg-anton-card ${expanded ? "fixed inset-4 z-50" : ""}`}>
    95	      <div className="flex items-center justify-between px-3 py-2 bg-anton-surface border-b border-anton-border">
    96	        <div className="flex items-center gap-2">
    97	          <GitBranch size={14} className="text-purple-400" />
    98	          <span className="text-xs font-medium text-white">{title || "Diagram"}</span>
    99	        </div>
   100	        <div className="flex items-center gap-1">
   101	          <button onClick={handleCopy} className="p-1.5 rounded text-anton-muted hover:text-white transition">
   102	            {copied ? <Check size={14} className="text-green-400" /> : <Copy size={14} />}
   103	          </button>
   104	          <button onClick={() => setExpanded(!expanded)} className="p-1.5 rounded text-anton-muted hover:text-white transition">
   105	            {expanded ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
   106	          </button>
   107	        </div>
   108	      </div>
   109	      <div
   110	        ref={containerRef}
   111	        className="p-6 flex justify-center overflow-auto bg-[#0f0f1a]"
   112	        style={{ minHeight: expanded ? "calc(100vh - 100px)" : "200px" }}
   113	        dangerouslySetInnerHTML={{ __html: svg }}
   114	      />
   115	    </div>
   116	  );
   117	}│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [052]: frontend/src/components/MermaidDiagram.jsx


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [053/69]: frontend/src/components/MessageBubble.jsx
│ LANGUAGE: jsx | LINES: 309 | SIZE: 13510 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	import React, { useState, useMemo, useCallback } from "react";
     2	import ReactMarkdown from "react-markdown";
     3	import remarkGfm from "remark-gfm";
     4	import CodeBlock from "./CodeBlock";
     5	import UIPreview, { buildPreviewHTML, isPreviewable } from "./UIPreview";
     6	import { getAttachmentUrl, extractCodeBlocks, commitFromChat, exportPptx, exportDocx } from "../api";
     7	import {
     8	  User, Flame, ChevronDown, ChevronRight, Brain, Copy, Check,
     9	  Image, Film, FileText, ExternalLink, GitCommitVertical, Loader2,
    10	  Presentation, FileOutput, Eye,
    11	} from "lucide-react";
    12	
    13	const FILE_TYPE_ICONS = { image: Image, video: Film, document: FileText, text: FileText };
    14	
    15	const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking, token, linkedRepo, onCommit, chatId }) {
    16	  const { role, content, thinking_content, input_tokens, output_tokens, attachments } = message;
    17	  const isUser = role === "user";
    18	  const [showThinking, setShowThinking] = useState(false);
    19	  const [copied, setCopied] = useState(false);
    20	  const [expandedImage, setExpandedImage] = useState(null);
    21	  const [batchCommitting, setBatchCommitting] = useState(false);
    22	  const [batchDone, setBatchDone] = useState(false);
    23	  const [exportingType, setExportingType] = useState("");
    24	  const [showUIPreview, setShowUIPreview] = useState(false);
    25	
    26	  const handleCopy = useCallback(() => {
    27	    navigator.clipboard.writeText(content || "");
    28	    setCopied(true);
    29	    setTimeout(() => setCopied(false), 2000);
    30	  }, [content]);
    31	
    32	  const committableBlocks = useMemo(() => {
    33	    if (isUser || !content || !linkedRepo) return [];
    34	    return extractCodeBlocks(content).filter(b => b.filename);
    35	  }, [content, isUser, linkedRepo]);
    36	
    37	  const codeBlocks = useMemo(() => {
    38	    if (isUser || !content) return [];
    39	    return extractCodeBlocks(content);
    40	  }, [content, isUser]);
    41	
    42	  const previewable = useMemo(() => isPreviewable(codeBlocks), [codeBlocks]);
    43	
    44	  const previewHTML = useMemo(() => {
    45	    if (!previewable) return null;
    46	    return buildPreviewHTML(codeBlocks);
    47	  }, [previewable, codeBlocks]);
    48	
    49	  async function handleBatchCommit() {
    50	    if (!committableBlocks.length || !linkedRepo || !chatId) return;
    51	    const msg = prompt(
    52	      `Commit ${committableBlocks.length} file(s) to ${linkedRepo.name}/${linkedRepo.default_branch}:`,
    53	      `Update ${committableBlocks.length} files via Son of Anton`
    54	    );
    55	    if (!msg) return;
    56	    setBatchCommitting(true);
    57	    try {
    58	      await commitFromChat(token, chatId, {
    59	        branch: linkedRepo.default_branch,
    60	        commit_message: msg,
    61	        files: committableBlocks.map(b => ({
    62	          file_path: b.filename,
    63	          content: b.code,
    64	          action: "auto",
    65	        })),
    66	      });
    67	      setBatchDone(true);
    68	      setTimeout(() => setBatchDone(false), 4000);
    69	    } catch (e) {
    70	      alert(`❌ ${e.message}`);
    71	    }
    72	    setBatchCommitting(false);
    73	  }
    74	
    75	  async function handleMsgExport(type) {
    76	    if (!content) return;
    77	    setExportingType(type);
    78	    try {
    79	      if (type === "pptx") await exportPptx(token, content, "response");
    80	      else await exportDocx(token, content, "response");
    81	    } catch (e) {
    82	      alert(`Export failed: ${e.message}`);
    83	    }
    84	    setExportingType("");
    85	  }
    86	
    87	  const hasAttachments = attachments && attachments.length > 0;
    88	
    89	  return (
    90	    <div className={`flex gap-2 sm:gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}>
    91	      {!isUser && (
    92	        <div className="shrink-0 mt-1">
    93	          <div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/10">
    94	            <Flame size={14} className="text-white" />
    95	          </div>
    96	        </div>
    97	      )}
    98	
    99	      <div className={`max-w-[85%] sm:max-w-[80%] ${isUser ? "order-first" : ""}`}>
   100	        {thinking_content && (
   101	          <div className="mb-2">
   102	            <button
   103	              onClick={() => setShowThinking(!showThinking)}
   104	              className="flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1"
   105	            >
   106	              <Brain size={12} />
   107	              {showThinking ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
   108	              {isThinking ? <span className="thinking-pulse">Reasoning…</span> : <span>View reasoning</span>}
   109	            </button>
   110	            {(showThinking || isThinking) && (
   111	              <div className="bg-purple-500/5 border border-purple-500/20 rounded-lg p-3 text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-60 overflow-y-auto">
   112	                {thinking_content}
   113	                {isThinking && <span className="inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse" />}
   114	              </div>
   115	            )}
   116	          </div>
   117	        )}
   118	
   119	        {hasAttachments && (
   120	          <div className="mb-2 flex flex-wrap gap-2">
   121	            {attachments.map((att) => {
   122	              const Icon = FILE_TYPE_ICONS[att.file_type] || FileText;
   123	              const url = getAttachmentUrl(att.id);
   124	              if (att.file_type === "image") {
   125	                return (
   126	                  <div key={att.id} className="relative group">
   127	                    <img
   128	                      src={`${url}?token=${token}`}
   129	                      alt={att.original_filename}
   130	                      className="max-w-[200px] sm:max-w-[240px] max-h-[160px] sm:max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition"
   131	                      onClick={() => setExpandedImage(expandedImage === att.id ? null : att.id)}
   132	                      onError={(e) => { e.target.style.display = "none"; }}
   133	                      loading="lazy"
   134	                    />
   135	                    {expandedImage === att.id && (
   136	                      <div
   137	                        className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 cursor-pointer"
   138	                        onClick={() => setExpandedImage(null)}
   139	                      >
   140	                        <img
   141	                          src={`${url}?token=${token}`}
   142	                          alt={att.original_filename}
   143	                          className="max-w-full max-h-full object-contain rounded-lg"
   144	                        />
   145	                      </div>
   146	                    )}
   147	                    <div className="absolute bottom-1 left-1 bg-black/60 text-[8px] text-white px-1.5 py-0.5 rounded">
   148	                      {att.original_filename}
   149	                    </div>
   150	                  </div>
   151	                );
   152	              }
   153	              return (
   154	                <a
   155	                  key={att.id}
   156	                  href={`${url}?token=${token}`}
   157	                  target="_blank"
   158	                  rel="noopener noreferrer"
   159	                  className="flex items-center gap-2 bg-anton-card border border-anton-border rounded-lg px-2.5 py-1.5 hover:border-anton-accent transition group"
   160	                >
   161	                  <Icon size={14} className="shrink-0 text-blue-400" />
   162	                  <div className="min-w-0">
   163	                    <div className="text-[11px] text-white truncate max-w-[120px]">{att.original_filename}</div>
   164	                    <div className="text-[9px] text-anton-muted">{(att.file_size / 1024).toFixed(0)}KB</div>
   165	                  </div>
   166	                  <ExternalLink size={10} className="text-anton-muted group-hover:text-anton-accent shrink-0" />
   167	                </a>
   168	              );
   169	            })}
   170	          </div>
   171	        )}
   172	
   173	        <div className={`rounded-2xl px-3 sm:px-4 py-2.5 sm:py-3 ${isUser
   174	          ? "bg-anton-accent text-white rounded-br-md"
   175	          : "bg-anton-card border border-anton-border rounded-bl-md"
   176	          }`}>
   177	          {isUser ? (
   178	            <div className="text-sm whitespace-pre-wrap">{_stripPrefixes(content)}</div>
   179	          ) : (
   180	            <div className="prose-anton text-sm">
   181	              <ReactMarkdown
   182	                remarkPlugins={[remarkGfm]}
   183	                components={{
   184	                  code({ node, inline, className, children, ...props }) {
   185	                    const match = /language-(\S+)/.exec(className || "");
   186	                    const rawLang = match?.[1] || "";
   187	                    if (inline) return <code className={className} {...props}>{children}</code>;
   188	                    let lang = rawLang, filename = null;
   189	                    if (rawLang.includes(":")) {
   190	                      const idx = rawLang.indexOf(":");
   191	                      lang = rawLang.slice(0, idx);
   192	                      filename = rawLang.slice(idx + 1);
   193	                    }
   194	                    return (
   195	                      <CodeBlock
   196	                        language={lang}
   197	                        filename={filename}
   198	                        code={String(children).replace(/\n$/, "")}
   199	                        linkedRepo={linkedRepo}
   200	                        onCommit={onCommit}
   201	                      />
   202	                    );
   203	                  },
   204	                  pre({ children }) { return <>{children}</>; },
   205	                }}
   206	              >
   207	                {content || ""}
   208	              </ReactMarkdown>
   209	              {isStreaming && !isThinking && (
   210	                <span className="inline-block w-1.5 h-4 bg-anton-accent ml-0.5 animate-pulse" />
   211	              )}
   212	            </div>
   213	          )}
   214	        </div>
   215	
   216	        {/* Preview UI Card */}
   217	        {!isUser && !isStreaming && previewable && previewHTML && (
   218	          <button
   219	            onClick={() => setShowUIPreview(true)}
   220	            className="mt-2 w-full flex items-center gap-3 px-4 py-3 rounded-xl border border-blue-500/30 bg-blue-500/5 hover:bg-blue-500/10 hover:border-blue-500/50 transition group"
   221	          >
   222	            <div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center shrink-0 group-hover:bg-blue-500/30 transition">
   223	              <Eye size={18} className="text-blue-400" />
   224	            </div>
   225	            <div className="text-left min-w-0">
   226	              <p className="text-sm text-white font-medium">Preview UI Design</p>
   227	              <p className="text-[10px] text-blue-400/70">
   228	                {codeBlocks.length} code block{codeBlocks.length > 1 ? "s" : ""} • Click to open live preview
   229	              </p>
   230	            </div>
   231	            <Eye size={16} className="text-blue-400/50 group-hover:text-blue-400 transition ml-auto shrink-0" />
   232	          </button>
   233	        )}
   234	
   235	        {!isUser && !isStreaming && content && (
   236	          <div className="flex items-center gap-2 sm:gap-3 mt-1.5 px-1 flex-wrap">
   237	            <button onClick={handleCopy} className="flex items-center gap-1 text-[10px] text-anton-muted hover:text-white transition">
   238	              {copied ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />}
   239	              {copied ? "Copied" : "Copy"}
   240	            </button>
   241	            {(input_tokens > 0 || output_tokens > 0) && (
   242	              <span className="text-[10px] text-anton-muted">
   243	                {input_tokens?.toLocaleString()}↓/{output_tokens?.toLocaleString()}↑
   244	              </span>
   245	            )}
   246	            {/* Per-message export */}
   247	            <button
   248	              onClick={() => handleMsgExport("pptx")}
   249	              disabled={!!exportingType}
   250	              className="flex items-center gap-1 text-[10px] text-anton-muted hover:text-orange-400 transition disabled:opacity-30"
   251	              title="Export as PPTX"
   252	            >
   253	              {exportingType === "pptx" ? <Loader2 size={10} className="animate-spin" /> : <Presentation size={10} />} PPTX
   254	            </button>
   255	            <button
   256	              onClick={() => handleMsgExport("docx")}
   257	              disabled={!!exportingType}
   258	              className="flex items-center gap-1 text-[10px] text-anton-muted hover:text-blue-400 transition disabled:opacity-30"
   259	              title="Export as DOCX"
   260	            >
   261	              {exportingType === "docx" ? <Loader2 size={10} className="animate-spin" /> : <FileOutput size={10} />} DOCX
   262	            </button>
   263	            {committableBlocks.length > 0 && !batchDone && (
   264	              <button
   265	                onClick={handleBatchCommit}
   266	                disabled={batchCommitting}
   267	                className="ml-auto flex items-center gap-1 text-[10px] text-orange-400 hover:text-orange-300 transition disabled:opacity-50"
   268	              >
   269	                {batchCommitting ? <Loader2 size={11} className="animate-spin" /> : <GitCommitVertical size={11} />}
   270	                Commit All ({committableBlocks.length})
   271	              </button>
   272	            )}
   273	            {batchDone && (
   274	              <span className="ml-auto flex items-center gap-1 text-[10px] text-green-400">
   275	                <Check size={11} /> Committed!
   276	              </span>
   277	            )}
   278	          </div>
   279	        )}
   280	      </div>
   281	
   282	      {isUser && (
   283	        <div className="shrink-0 mt-1">
   284	          <div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center">
   285	            <User size={14} className="text-anton-muted" />
   286	          </div>
   287	        </div>
   288	      )}
   289	
   290	      {/* UI Preview Modal */}
   291	      {showUIPreview && previewHTML && (
   292	        <UIPreview
   293	          html={previewHTML}
   294	          title="UI Preview"
   295	          onClose={() => setShowUIPreview(false)}
   296	        />
   297	      )}
   298	    </div>
   299	  );
   300	});
   301	
   302	function _stripPrefixes(text) {
   303	  if (!text) return "";
   304	  return text
   305	    .replace(/^\[(?:Image|Video|Document|File):\s[^\]]*\]\n?/gm, "")
   306	    .replace(/^\[UI DESIGN MODE\][\s\S]*?\n\n/m, "")
   307	    .trim();
   308	}
   309	
   310	export default MessageBubble;│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [053]: frontend/src/components/MessageBubble.jsx


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [054/69]: frontend/src/components/Sidebar.jsx
│ LANGUAGE: jsx | LINES: 119 | SIZE: 5929 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	import React, { useState, useEffect } from "react";
     2	import { useApp, usePermissions } from "../store";
     3	import { useNavigate } from "react-router-dom";
     4	import { listChats, createChat, deleteChat, renameChat } from "../api";
     5	import {
     6	  Plus, Trash2, MessageSquare, Flame, LogOut, Shield, BookOpen,
     7	  Edit3, Check, X, GitBranch,
     8	} from "lucide-react";
     9	
    10	export default function Sidebar({ mobile, onClose }) {
    11	  const { state, dispatch } = useApp();
    12	  const perms = usePermissions();
    13	  const nav = useNavigate();
    14	  const activeChatId = state.activeChatId;
    15	  const [editId, setEditId] = useState(null);
    16	  const [editTitle, setEditTitle] = useState("");
    17	
    18	  useEffect(() => {
    19	    (async () => {
    20	      try {
    21	        const chats = await listChats(state.token);
    22	        dispatch({ type: "SET_CHATS", chats });
    23	      } catch { }
    24	    })();
    25	  }, [state.token, dispatch]);
    26	
    27	  function handleSelectChat(chatId) {
    28	    dispatch({ type: "SET_ACTIVE_CHAT", chatId });
    29	    if (onClose) onClose();
    30	  }
    31	
    32	  async function handleNew() {
    33	    try {
    34	      const chat = await createChat(state.token);
    35	      dispatch({ type: "ADD_CHAT", chat });
    36	    } catch (e) { alert(e.message); }
    37	  }
    38	
    39	  async function handleDelete(e, chatId) {
    40	    e.stopPropagation();
    41	    if (!confirm("Delete this chat?")) return;
    42	    try { await deleteChat(state.token, chatId); dispatch({ type: "REMOVE_CHAT", chatId }); } catch { }
    43	  }
    44	
    45	  async function handleRename(chatId) {
    46	    if (!editTitle.trim()) { setEditId(null); return; }
    47	    try { await renameChat(state.token, chatId, editTitle.trim()); dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, title: editTitle.trim() } }); } catch { }
    48	    setEditId(null);
    49	  }
    50	
    51	  const isSuperadmin = state.user?.role === "superadmin";
    52	
    53	  return (
    54	    <div className={`${mobile ? "h-full" : "h-dvh"} w-72 bg-anton-surface border-r border-anton-border flex flex-col`}>
    55	      <div className="p-3 border-b border-anton-border">
    56	        <div className="flex items-center gap-2 mb-3">
    57	          <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center">
    58	            <Flame size={16} className="text-white" />
    59	          </div>
    60	          <div>
    61	            <h1 className="text-sm font-bold text-white">Son of Anton</h1>
    62	            <p className="text-[10px] text-anton-muted">v4.2.0 — The Architect</p>
    63	          </div>
    64	        </div>
    65	        <button onClick={handleNew} className="w-full flex items-center justify-center gap-1.5 bg-anton-accent text-white rounded-lg py-2 text-sm hover:opacity-80 transition">
    66	          <Plus size={16} /> New Chat
    67	        </button>
    68	      </div>
    69	
    70	      <div className="flex-1 overflow-y-auto p-2 space-y-0.5">
    71	        {state.chats.map((c) => (
    72	          <div key={c.id} onClick={() => handleSelectChat(c.id)}
    73	            className={`group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition text-sm ${activeChatId === c.id ? "bg-anton-accent/15 text-white" : "text-anton-muted hover:bg-anton-card hover:text-white"}`}>
    74	            <MessageSquare size={14} className="shrink-0" />
    75	            {editId === c.id ? (
    76	              <div className="flex-1 flex items-center gap-1">
    77	                <input value={editTitle} onChange={(e) => setEditTitle(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleRename(c.id)}
    78	                  className="flex-1 bg-anton-bg border border-anton-border rounded px-1.5 py-0.5 text-xs text-white" autoFocus onClick={(e) => e.stopPropagation()} />
    79	                <button onClick={(e) => { e.stopPropagation(); handleRename(c.id); }} className="text-green-400"><Check size={12} /></button>
    80	                <button onClick={(e) => { e.stopPropagation(); setEditId(null); }} className="text-red-400"><X size={12} /></button>
    81	              </div>
    82	            ) : (
    83	              <>
    84	                <span className="flex-1 truncate text-xs">{c.title}</span>
    85	                <div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
    86	                  {c.linked_repo_id && <GitBranch size={11} className="text-orange-400" />}
    87	                  <button onClick={(e) => { e.stopPropagation(); setEditId(c.id); setEditTitle(c.title); }} className="p-0.5 hover:text-anton-accent"><Edit3 size={11} /></button>
    88	                  <button onClick={(e) => handleDelete(e, c.id)} className="p-0.5 hover:text-red-400"><Trash2 size={11} /></button>
    89	                </div>
    90	              </>
    91	            )}
    92	          </div>
    93	        ))}
    94	      </div>
    95	
    96	      <div className="p-2 border-t border-anton-border space-y-0.5">
    97	        {/* GitLab — only if superadmin OR has gitlab permission */}
    98	        {(isSuperadmin || perms.can_use_gitlab) && (
    99	          <button onClick={() => nav("/gitlab")} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-orange-400 hover:bg-anton-card transition">
   100	            <GitBranch size={14} /> GitLab Center
   101	          </button>
   102	        )}
   103	        {isSuperadmin && (
   104	          <button onClick={() => nav("/admin")} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card hover:text-white transition">
   105	            <Shield size={14} /> Admin
   106	          </button>
   107	        )}
   108	        {perms.can_use_knowledge_base && (
   109	          <button onClick={() => nav("/knowledge")} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card hover:text-white transition">
   110	            <BookOpen size={14} /> Knowledge
   111	          </button>
   112	        )}
   113	        <button onClick={() => dispatch({ type: "LOGOUT" })} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card hover:text-red-400 transition">
   114	          <LogOut size={14} /> Logout
   115	        </button>
   116	        <div className="px-3 py-1 text-[10px] text-anton-muted">{state.user?.username} • {state.user?.role}</div>
   117	      </div>
   118	    </div>
   119	  );
   120	}│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [054]: frontend/src/components/Sidebar.jsx


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [055/69]: frontend/src/components/UIPreview.jsx
│ LANGUAGE: jsx | LINES: 640 | SIZE: 23827 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
     2	import {
     3	  X, Monitor, Tablet, Smartphone, Maximize2, RefreshCw,
     4	  Download, Copy, Check, Code2, Eye, Columns, ZoomIn, ZoomOut,
     5	  ExternalLink, RotateCcw, Sun, Moon, Paintbrush,
     6	} from "lucide-react";
     7	
     8	/* ═══════════════════════════════════════════════════
     9	   VIEWPORT PRESETS
    10	   ═══════════════════════════════════════════════════ */
    11	const VIEWPORTS = [
    12	  { id: "desktop", label: "Desktop", width: 1440, height: 900, icon: Monitor },
    13	  { id: "tablet", label: "Tablet", width: 768, height: 1024, icon: Tablet },
    14	  { id: "mobile", label: "Mobile", width: 375, height: 812, icon: Smartphone },
    15	  { id: "full", label: "Responsive", width: null, height: null, icon: Maximize2 },
    16	];
    17	
    18	const ZOOM_LEVELS = [25, 50, 75, 100, 125, 150, 200];
    19	
    20	/* ═══════════════════════════════════════════════════
    21	   HTML BUILDER — Combines code blocks into a page
    22	   ═══════════════════════════════════════════════════ */
    23	const TAILWIND_RE = /class="[^"]*(?:flex|grid|p-\d|m-\d|bg-|text-(?:sm|lg|xl|\w+-\d)|rounded|shadow|border|w-|h-|gap-|space-|items-|justify-)/;
    24	const REACT_IMPORT_RE = /(?:import\s+.*from\s+['"]react|React\.|useState|useEffect|useRef|jsx|<\w+[A-Z])/;
    25	
    26	export function buildPreviewHTML(blocks) {
    27	  if (!blocks || !blocks.length) return null;
    28	
    29	  const htmlBlocks = blocks.filter(
    30	    (b) => b.language === "html" || b.filename?.match(/\.html?$/)
    31	  );
    32	  const cssBlocks = blocks.filter(
    33	    (b) => b.language === "css" || b.language === "scss" || b.filename?.match(/\.s?css$/)
    34	  );
    35	  const jsBlocks = blocks.filter(
    36	    (b) =>
    37	      ["javascript", "js"].includes(b.language) ||
    38	      (b.filename?.match(/\.m?js$/) && !b.filename?.match(/\.jsx$/))
    39	  );
    40	  const reactBlocks = blocks.filter(
    41	    (b) =>
    42	      ["jsx", "tsx", "react"].includes(b.language) ||
    43	      b.filename?.match(/\.(jsx|tsx)$/) ||
    44	      (["javascript", "js", "typescript", "ts"].includes(b.language) &&
    45	        REACT_IMPORT_RE.test(b.code))
    46	  );
    47	  const vueBlocks = blocks.filter(
    48	    (b) => b.language === "vue" || b.filename?.match(/\.vue$/)
    49	  );
    50	  const svelteBlocks = blocks.filter(
    51	    (b) => b.language === "svelte" || b.filename?.match(/\.svelte$/)
    52	  );
    53	
    54	  // ── Case 1: Complete HTML file ──
    55	  if (htmlBlocks.length) {
    56	    let html = htmlBlocks[0].code;
    57	
    58	    // Check if it's a full document or a fragment
    59	    const isFullDoc = /<!DOCTYPE|<html/i.test(html);
    60	
    61	    if (!isFullDoc) {
    62	      html = _wrapFragment(html, cssBlocks, jsBlocks);
    63	    } else {
    64	      // Inject additional CSS blocks
    65	      if (cssBlocks.length) {
    66	        const css = cssBlocks.map((b) => b.code).join("\n\n");
    67	        if (html.includes("</head>")) {
    68	          html = html.replace("</head>", `<style>\n${css}\n</style>\n</head>`);
    69	        } else {
    70	          html = `<style>\n${css}\n</style>\n` + html;
    71	        }
    72	      }
    73	      // Inject additional JS blocks
    74	      if (jsBlocks.length) {
    75	        const js = jsBlocks.map((b) => b.code).join("\n\n");
    76	        if (html.includes("</body>")) {
    77	          html = html.replace("</body>", `<script>\n${js}\n</script>\n</body>`);
    78	        } else {
    79	          html += `\n<script>\n${js}\n</script>`;
    80	        }
    81	      }
    82	      // Auto-inject Tailwind CDN if needed
    83	      html = _injectTailwindIfNeeded(html);
    84	    }
    85	    return html;
    86	  }
    87	
    88	  // ── Case 2: React/JSX ──
    89	  if (reactBlocks.length) {
    90	    return _buildReactPreview(reactBlocks, cssBlocks, jsBlocks);
    91	  }
    92	
    93	  // ── Case 3: Vue SFC ──
    94	  if (vueBlocks.length) {
    95	    return _buildVuePreview(vueBlocks, cssBlocks);
    96	  }
    97	
    98	  // ── Case 4: CSS only (showcase) ──
    99	  if (cssBlocks.length && !jsBlocks.length) {
   100	    return _buildCSSShowcase(cssBlocks);
   101	  }
   102	
   103	  // ── Case 5: JS only ──
   104	  if (jsBlocks.length) {
   105	    return _wrapFragment("", cssBlocks, jsBlocks);
   106	  }
   107	
   108	  return null;
   109	}
   110	
   111	function _wrapFragment(bodyHTML, cssBlocks, jsBlocks) {
   112	  const css = cssBlocks.map((b) => b.code).join("\n\n");
   113	  const js = jsBlocks.map((b) => b.code).join("\n\n");
   114	  const combined = `<!DOCTYPE html>
   115	<html lang="en">
   116	<head>
   117	  <meta charset="UTF-8">
   118	  <meta name="viewport" content="width=device-width, initial-scale=1.0">
   119	  <title>UI Preview</title>
   120	  ${css ? `<style>\n${css}\n</style>` : ""}
   121	</head>
   122	<body>
   123	  ${bodyHTML}
   124	  ${js ? `<script>\n${js}\n</script>` : ""}
   125	</body>
   126	</html>`;
   127	  return _injectTailwindIfNeeded(combined);
   128	}
   129	
   130	function _buildReactPreview(reactBlocks, cssBlocks, jsBlocks) {
   131	  const css = cssBlocks.map((b) => b.code).join("\n\n");
   132	  const jsx = reactBlocks.map((b) => b.code).join("\n\n");
   133	  const js = jsBlocks.map((b) => b.code).join("\n\n");
   134	
   135	  // Strip import/export statements for browser execution
   136	  const cleanJSX = jsx
   137	    .replace(/^import\s+.*?from\s+['"][^'"]+['"];?\s*$/gm, "")
   138	    .replace(/^export\s+default\s+/gm, "const __DefaultExport__ = ")
   139	    .replace(/^export\s+/gm, "");
   140	
   141	  // Find the main component name
   142	  const componentMatch = cleanJSX.match(
   143	    /(?:function|const|class)\s+([A-Z]\w+)/
   144	  );
   145	  const mainComponent = componentMatch
   146	    ? componentMatch[1]
   147	    : "__DefaultExport__";
   148	
   149	  const html = `<!DOCTYPE html>
   150	<html lang="en">
   151	<head>
   152	  <meta charset="UTF-8">
   153	  <meta name="viewport" content="width=device-width, initial-scale=1.0">
   154	  <title>React Preview</title>
   155	  <script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin><\/script>
   156	  <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin><\/script>
   157	  <script src="https://unpkg.com/@babel/standalone/babel.min.js"><\/script>
   158	  ${css ? `<style>\n${css}\n</style>` : ""}
   159	  <style>
   160	    * { margin: 0; padding: 0; box-sizing: border-box; }
   161	    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
   162	  </style>
   163	</head>
   164	<body>
   165	  <div id="root"></div>
   166	  ${js ? `<script>\n${js}\n<\/script>` : ""}
   167	  <script type="text/babel">
   168	    const { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext, useReducer } = React;
   169	    ${cleanJSX}
   170	    const root = ReactDOM.createRoot(document.getElementById('root'));
   171	    root.render(React.createElement(${mainComponent}));
   172	  <\/script>
   173	</body>
   174	</html>`;
   175	  return _injectTailwindIfNeeded(html);
   176	}
   177	
   178	function _buildVuePreview(vueBlocks, cssBlocks) {
   179	  const vue = vueBlocks[0].code;
   180	  const css = cssBlocks.map((b) => b.code).join("\n\n");
   181	
   182	  // Extract template, script, style from SFC
   183	  const templateMatch = vue.match(/<template>([\s\S]*?)<\/template>/);
   184	  const scriptMatch = vue.match(/<script[^>]*>([\s\S]*?)<\/script>/);
   185	  const styleMatch = vue.match(/<style[^>]*>([\s\S]*?)<\/style>/);
   186	
   187	  const template = templateMatch ? templateMatch[1] : "<div>Vue Component</div>";
   188	  const script = scriptMatch ? scriptMatch[1] : "";
   189	  const style = styleMatch ? styleMatch[1] : "";
   190	
   191	  return `<!DOCTYPE html>
   192	<html lang="en">
   193	<head>
   194	  <meta charset="UTF-8">
   195	  <meta name="viewport" content="width=device-width, initial-scale=1.0">
   196	  <title>Vue Preview</title>
   197	  <script src="https://unpkg.com/vue@3/dist/vue.global.js"><\/script>
   198	  <style>${css}\n${style}</style>
   199	</head>
   200	<body>
   201	  <div id="app">${template}</div>
   202	  <script>
   203	    ${script.replace(/export\s+default\s*/, "const __comp__ = ")}
   204	    Vue.createApp(typeof __comp__ !== 'undefined' ? __comp__ : {}).mount('#app');
   205	  <\/script>
   206	</body>
   207	</html>`;
   208	}
   209	
   210	function _buildCSSShowcase(cssBlocks) {
   211	  const css = cssBlocks.map((b) => b.code).join("\n\n");
   212	  return `<!DOCTYPE html>
   213	<html lang="en">
   214	<head>
   215	  <meta charset="UTF-8">
   216	  <meta name="viewport" content="width=device-width, initial-scale=1.0">
   217	  <title>CSS Preview</title>
   218	  <style>
   219	    * { margin: 0; padding: 0; box-sizing: border-box; }
   220	    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 2rem; }
   221	    ${css}
   222	  </style>
   223	</head>
   224	<body>
   225	  <div class="preview-container">
   226	    <h1>CSS Preview</h1>
   227	    <p>Your styles are applied to this page. Add HTML elements to see them in action.</p>
   228	  </div>
   229	</body>
   230	</html>`;
   231	}
   232	
   233	function _injectTailwindIfNeeded(html) {
   234	  if (!TAILWIND_RE.test(html)) return html;
   235	  if (html.includes("tailwindcss") || html.includes("tailwind.")) return html;
   236	  const tailwindScript = '<script src="https://cdn.tailwindcss.com"><\/script>';
   237	  if (html.includes("</head>")) {
   238	    return html.replace("</head>", `  ${tailwindScript}\n</head>`);
   239	  }
   240	  return tailwindScript + "\n" + html;
   241	}
   242	
   243	export function isPreviewable(blocks) {
   244	  if (!blocks || !blocks.length) return false;
   245	  const langs = new Set(blocks.map((b) => b.language));
   246	  const files = blocks.map((b) => b.filename || "").join(" ");
   247	  if (langs.has("html") || files.match(/\.html?/)) return true;
   248	  if (langs.has("jsx") || langs.has("tsx") || files.match(/\.(jsx|tsx)/)) return true;
   249	  if (langs.has("vue") || files.match(/\.vue/)) return true;
   250	  if (langs.has("svelte") || files.match(/\.svelte/)) return true;
   251	  // CSS + JS combo
   252	  if (
   253	    (langs.has("css") || langs.has("scss")) &&
   254	    (langs.has("javascript") || langs.has("js"))
   255	  )
   256	    return true;
   257	  // Single HTML-like code that starts with tags
   258	  for (const b of blocks) {
   259	    if (b.code.trim().match(/^<!DOCTYPE|^<html|^<div|^<section|^<main|^<header/i))
   260	      return true;
   261	  }
   262	  return false;
   263	}
   264	
   265	/* ═══════════════════════════════════════════════════
   266	   MAIN COMPONENT
   267	   ═══════════════════════════════════════════════════ */
   268	export default function UIPreview({ html: initialHtml, title, onClose }) {
   269	  const [html, setHtml] = useState(initialHtml);
   270	  const [editHtml, setEditHtml] = useState(initialHtml);
   271	  const [viewport, setViewport] = useState("full");
   272	  const [viewMode, setViewMode] = useState("preview");
   273	  const [zoom, setZoom] = useState(100);
   274	  const [copied, setCopied] = useState(false);
   275	  const [blobUrl, setBlobUrl] = useState(null);
   276	  const [iframeKey, setIframeKey] = useState(0);
   277	  const [darkBg, setDarkBg] = useState(false);
   278	  const iframeRef = useRef(null);
   279	  const containerRef = useRef(null);
   280	
   281	  // Create blob URL for iframe
   282	  useEffect(() => {
   283	    if (!html) return;
   284	    const blob = new Blob([html], { type: "text/html;charset=utf-8" });
   285	    const url = URL.createObjectURL(blob);
   286	    setBlobUrl(url);
   287	    return () => URL.revokeObjectURL(url);
   288	  }, [html, iframeKey]);
   289	
   290	  // Escape key to close
   291	  useEffect(() => {
   292	    const onKey = (e) => {
   293	      if (e.key === "Escape") onClose?.();
   294	    };
   295	    window.addEventListener("keydown", onKey);
   296	    return () => window.removeEventListener("keydown", onKey);
   297	  }, [onClose]);
   298	
   299	  const handleRefresh = useCallback(() => {
   300	    setIframeKey((k) => k + 1);
   301	  }, []);
   302	
   303	  const handleApplyCode = useCallback(() => {
   304	    setHtml(editHtml);
   305	    setIframeKey((k) => k + 1);
   306	  }, [editHtml]);
   307	
   308	  const handleCopy = useCallback(() => {
   309	    navigator.clipboard.writeText(html);
   310	    setCopied(true);
   311	    setTimeout(() => setCopied(false), 2000);
   312	  }, [html]);
   313	
   314	  const handleDownload = useCallback(() => {
   315	    const blob = new Blob([html], { type: "text/html" });
   316	    const url = URL.createObjectURL(blob);
   317	    const a = document.createElement("a");
   318	    a.href = url;
   319	    const safeName = (title || "design")
   320	      .replace(/[^\w\s-]/g, "")
   321	      .trim()
   322	      .replace(/\s+/g, "-")
   323	      .slice(0, 50);
   324	    a.download = `${safeName || "design"}.html`;
   325	    a.click();
   326	    URL.revokeObjectURL(url);
   327	  }, [html, title]);
   328	
   329	  const handleOpenExternal = useCallback(() => {
   330	    if (!html) return;
   331	    const blob = new Blob([html], { type: "text/html;charset=utf-8" });
   332	    const url = URL.createObjectURL(blob);
   333	    window.open(url, "_blank");
   334	    // Don't revoke immediately — let the new tab load
   335	    setTimeout(() => URL.revokeObjectURL(url), 5000);
   336	  }, [html]);
   337	
   338	  const handleZoomIn = () =>
   339	    setZoom((z) => Math.min(z + 25, ZOOM_LEVELS[ZOOM_LEVELS.length - 1]));
   340	  const handleZoomOut = () => setZoom((z) => Math.max(z - 25, ZOOM_LEVELS[0]));
   341	
   342	  const currentVP = VIEWPORTS.find((v) => v.id === viewport);
   343	  const iframeWidth = currentVP?.width || "100%";
   344	  const iframeHeight = currentVP?.height || "100%";
   345	
   346	  const showPreview = viewMode === "preview" || viewMode === "split";
   347	  const showCode = viewMode === "code" || viewMode === "split";
   348	
   349	  return (
   350	    <div className="fixed inset-0 z-[100] bg-black/90 backdrop-blur-sm flex flex-col animate-fade-in">
   351	      {/* ═══ HEADER ═══ */}
   352	      <div className="flex items-center gap-2 px-3 py-2 bg-anton-surface border-b border-anton-border shrink-0 flex-wrap">
   353	        {/* Close + Title */}
   354	        <button
   355	          onClick={onClose}
   356	          className="p-1.5 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition"
   357	        >
   358	          <X size={18} />
   359	        </button>
   360	        <div className="flex items-center gap-2 min-w-0 mr-2">
   361	          <Paintbrush size={16} className="text-anton-accent shrink-0" />
   362	          <span className="text-sm font-semibold text-white truncate max-w-[200px]">
   363	            {title || "UI Preview"}
   364	          </span>
   365	        </div>
   366	
   367	        {/* View Mode Tabs */}
   368	        <div className="flex items-center bg-anton-bg rounded-lg p-0.5 border border-anton-border">
   369	          {[
   370	            { id: "preview", label: "Preview", icon: Eye },
   371	            { id: "code", label: "Code", icon: Code2 },
   372	            { id: "split", label: "Split", icon: Columns },
   373	          ].map(({ id, label, icon: Icon }) => (
   374	            <button
   375	              key={id}
   376	              onClick={() => setViewMode(id)}
   377	              className={`flex items-center gap-1 px-2.5 py-1.5 rounded-md text-xs font-medium transition ${
   378	                viewMode === id
   379	                  ? "bg-anton-accent text-white shadow"
   380	                  : "text-anton-muted hover:text-white"
   381	              }`}
   382	            >
   383	              <Icon size={13} />
   384	              <span className="hidden sm:inline">{label}</span>
   385	            </button>
   386	          ))}
   387	        </div>
   388	
   389	        {/* Viewport Controls */}
   390	        <div className="flex items-center gap-0.5 ml-2">
   391	          {VIEWPORTS.map(({ id, label, icon: Icon, width }) => (
   392	            <button
   393	              key={id}
   394	              onClick={() => setViewport(id)}
   395	              className={`p-1.5 rounded-lg transition relative group ${
   396	                viewport === id
   397	                  ? "bg-anton-accent/20 text-anton-accent"
   398	                  : "text-anton-muted hover:text-white hover:bg-anton-card"
   399	              }`}
   400	              title={`${label}${width ? ` (${width}px)` : ""}`}
   401	            >
   402	              <Icon size={15} />
   403	            </button>
   404	          ))}
   405	        </div>
   406	
   407	        {/* Zoom */}
   408	        <div className="flex items-center gap-0.5 ml-2">
   409	          <button
   410	            onClick={handleZoomOut}
   411	            className="p-1 rounded text-anton-muted hover:text-white transition"
   412	            disabled={zoom <= ZOOM_LEVELS[0]}
   413	          >
   414	            <ZoomOut size={14} />
   415	          </button>
   416	          <span className="text-[10px] text-anton-muted font-mono w-8 text-center">
   417	            {zoom}%
   418	          </span>
   419	          <button
   420	            onClick={handleZoomIn}
   421	            className="p-1 rounded text-anton-muted hover:text-white transition"
   422	            disabled={zoom >= ZOOM_LEVELS[ZOOM_LEVELS.length - 1]}
   423	          >
   424	            <ZoomIn size={14} />
   425	          </button>
   426	        </div>
   427	
   428	        {/* Spacer */}
   429	        <div className="flex-1" />
   430	
   431	        {/* Actions */}
   432	        <div className="flex items-center gap-1">
   433	          <button
   434	            onClick={() => setDarkBg(!darkBg)}
   435	            className={`p-1.5 rounded-lg transition ${
   436	              darkBg
   437	                ? "bg-gray-700 text-yellow-300"
   438	                : "text-anton-muted hover:text-white hover:bg-anton-card"
   439	            }`}
   440	            title="Toggle background"
   441	          >
   442	            {darkBg ? <Moon size={14} /> : <Sun size={14} />}
   443	          </button>
   444	          <button
   445	            onClick={handleRefresh}
   446	            className="p-1.5 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition"
   447	            title="Refresh preview"
   448	          >
   449	            <RefreshCw size={14} />
   450	          </button>
   451	          <button
   452	            onClick={handleOpenExternal}
   453	            className="p-1.5 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition"
   454	            title="Open in new tab"
   455	          >
   456	            <ExternalLink size={14} />
   457	          </button>
   458	          <button
   459	            onClick={handleCopy}
   460	            className="p-1.5 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition"
   461	            title="Copy HTML"
   462	          >
   463	            {copied ? (
   464	              <Check size={14} className="text-green-400" />
   465	            ) : (
   466	              <Copy size={14} />
   467	            )}
   468	          </button>
   469	          <button
   470	            onClick={handleDownload}
   471	            className="p-1.5 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition"
   472	            title="Download HTML"
   473	          >
   474	            <Download size={14} />
   475	          </button>
   476	        </div>
   477	      </div>
   478	
   479	      {/* ═══ VIEWPORT INDICATOR ═══ */}
   480	      {viewport !== "full" && (
   481	        <div className="text-center py-1 text-[10px] text-anton-muted bg-anton-bg/50 border-b border-anton-border shrink-0">
   482	          {currentVP?.label} — {currentVP?.width}×{currentVP?.height}px
   483	          {zoom !== 100 && ` @ ${zoom}%`}
   484	        </div>
   485	      )}
   486	
   487	      {/* ═══ MAIN CONTENT ═══ */}
   488	      <div
   489	        ref={containerRef}
   490	        className="flex-1 flex overflow-hidden min-h-0"
   491	      >
   492	        {/* CODE PANEL */}
   493	        {showCode && (
   494	          <div
   495	            className={`flex flex-col bg-[#1a1b26] border-r border-anton-border overflow-hidden ${
   496	              viewMode === "split" ? "w-1/2" : "w-full"
   497	            }`}
   498	          >
   499	            <div className="flex items-center justify-between px-3 py-1.5 border-b border-anton-border bg-anton-surface">
   500	              <span className="text-[10px] text-anton-accent font-mono uppercase">
   501	                HTML
   502	              </span>
   503	              <div className="flex gap-1">
   504	                <button
   505	                  onClick={() => setEditHtml(initialHtml)}
   506	                  className="text-[10px] text-anton-muted hover:text-white px-1.5 py-0.5 rounded transition flex items-center gap-1"
   507	                  title="Reset to original"
   508	                >
   509	                  <RotateCcw size={10} /> Reset
   510	                </button>
   511	                <button
   512	                  onClick={handleApplyCode}
   513	                  className="text-[10px] bg-anton-accent text-white px-2 py-0.5 rounded hover:opacity-80 transition"
   514	                >
   515	                  Apply Changes
   516	                </button>
   517	              </div>
   518	            </div>
   519	            <textarea
   520	              value={editHtml}
   521	              onChange={(e) => setEditHtml(e.target.value)}
   522	              className="flex-1 w-full bg-transparent text-[12px] text-[#d4d4d4] font-mono p-3 resize-none focus:outline-none leading-relaxed"
   523	              spellCheck={false}
   524	              wrap="off"
   525	            />
   526	          </div>
   527	        )}
   528	
   529	        {/* PREVIEW PANEL */}
   530	        {showPreview && (
   531	          <div
   532	            className={`flex-1 flex items-start justify-center overflow-auto ${
   533	              darkBg ? "bg-[#0a0a0a]" : "bg-[#f0f0f0]"
   534	            } ${viewMode === "split" ? "w-1/2" : "w-full"}`}
   535	            style={{ padding: viewport === "full" ? 0 : "24px" }}
   536	          >
   537	            <div
   538	              className="relative transition-all duration-300 ease-out"
   539	              style={{
   540	                width:
   541	                  viewport === "full"
   542	                    ? "100%"
   543	                    : `${(currentVP?.width || 1440) * (zoom / 100)}px`,
   544	                height: viewport === "full" ? "100%" : "auto",
   545	                maxWidth: "100%",
   546	              }}
   547	            >
   548	              {/* Device Frame */}
   549	              {viewport !== "full" && (
   550	                <div
   551	                  className={`rounded-xl overflow-hidden shadow-2xl ${
   552	                    darkBg ? "shadow-black/50" : "shadow-gray-400/30"
   553	                  }`}
   554	                  style={{
   555	                    border: `${viewport === "mobile" ? 8 : viewport === "tablet" ? 6 : 2}px solid ${
   556	                      darkBg ? "#222" : "#ccc"
   557	                    }`,
   558	                    borderRadius:
   559	                      viewport === "mobile"
   560	                        ? "32px"
   561	                        : viewport === "tablet"
   562	                          ? "20px"
   563	                          : "8px",
   564	                  }}
   565	                >
   566	                  {/* Notch for mobile */}
   567	                  {viewport === "mobile" && (
   568	                    <div
   569	                      className="flex justify-center py-1"
   570	                      style={{
   571	                        background: darkBg ? "#222" : "#ccc",
   572	                      }}
   573	                    >
   574	                      <div
   575	                        className="rounded-full"
   576	                        style={{
   577	                          width: "80px",
   578	                          height: "6px",
   579	                          background: darkBg ? "#333" : "#aaa",
   580	                        }}
   581	                      />
   582	                    </div>
   583	                  )}
   584	                  {blobUrl && (
   585	                    <iframe
   586	                      ref={iframeRef}
   587	                      key={`frame-${iframeKey}`}
   588	                      src={blobUrl}
   589	                      sandbox="allow-scripts allow-popups allow-forms allow-modals"
   590	                      style={{
   591	                        width: `${currentVP.width}px`,
   592	                        height: `${currentVP.height}px`,
   593	                        transform: `scale(${zoom / 100})`,
   594	                        transformOrigin: "top left",
   595	                        border: "none",
   596	                        display: "block",
   597	                        background: "white",
   598	                      }}
   599	                      title="UI Preview"
   600	                    />
   601	                  )}
   602	                </div>
   603	              )}
   604	
   605	              {/* Full responsive mode */}
   606	              {viewport === "full" && blobUrl && (
   607	                <iframe
   608	                  ref={iframeRef}
   609	                  key={`frame-${iframeKey}`}
   610	                  src={blobUrl}
   611	                  sandbox="allow-scripts allow-popups allow-forms allow-modals"
   612	                  className="w-full border-none"
   613	                  style={{
   614	                    height: "100%",
   615	                    minHeight: "calc(100vh - 120px)",
   616	                    transform: zoom !== 100 ? `scale(${zoom / 100})` : undefined,
   617	                    transformOrigin: "top left",
   618	                    background: "white",
   619	                  }}
   620	                  title="UI Preview"
   621	                />
   622	              )}
   623	            </div>
   624	          </div>
   625	        )}
   626	      </div>
   627	
   628	      {/* ═══ FOOTER STATUS BAR ═══ */}
   629	      <div className="flex items-center justify-between px-3 py-1.5 bg-anton-surface border-t border-anton-border text-[10px] text-anton-muted shrink-0">
   630	        <span>
   631	          {html.length.toLocaleString()} chars • Sandbox: scripts only
   632	        </span>
   633	        <span>
   634	          Son of Anton UI Preview •{" "}
   635	          <kbd className="px-1 py-0.5 bg-anton-bg rounded text-[9px]">Esc</kbd>{" "}
   636	          to close
   637	        </span>
   638	      </div>
   639	    </div>
   640	  );
   641	}│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [055]: frontend/src/components/UIPreview.jsx


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [056/69]: frontend/src/index.css
│ LANGUAGE: css | LINES: 284 | SIZE: 8919 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	@tailwind base;
     2	@tailwind components;
     3	@tailwind utilities;
     4	
     5	/* ═══════════════════════════════════════════════════
     6	   ROOT VARIABLES & BASE
     7	   ═══════════════════════════════════════════════════ */
     8	
     9	:root {
    10	  --sat: env(safe-area-inset-top, 0px);
    11	  --sar: env(safe-area-inset-right, 0px);
    12	  --sab: env(safe-area-inset-bottom, 0px);
    13	  --sal: env(safe-area-inset-left, 0px);
    14	  --header-h: 3.25rem;
    15	  color-scheme: dark;
    16	}
    17	
    18	/* ═══════════════════════════════════════════════════
    19	   GLOBAL RESETS FOR MOBILE
    20	   ═══════════════════════════════════════════════════ */
    21	
    22	*, *::before, *::after {
    23	  -webkit-tap-highlight-color: transparent;
    24	  -webkit-touch-callout: none;
    25	}
    26	
    27	html {
    28	  overflow: hidden;
    29	  height: 100%;
    30	  height: 100dvh;
    31	}
    32	
    33	body {
    34	  overflow: hidden;
    35	  height: 100%;
    36	  height: 100dvh;
    37	  overscroll-behavior: none;
    38	  -webkit-overflow-scrolling: touch;
    39	  font-family: 'Inter', system-ui, -apple-system, sans-serif;
    40	  position: fixed;
    41	  width: 100%;
    42	  top: 0;
    43	  left: 0;
    44	}
    45	
    46	#root {
    47	  height: 100%;
    48	  height: 100dvh;
    49	  overflow: hidden;
    50	  display: flex;
    51	  flex-direction: column;
    52	}
    53	
    54	/* ═══════════════════════════════════════════════════
    55	   SAFE AREA UTILITIES
    56	   ═══════════════════════════════════════════════════ */
    57	
    58	.safe-top { padding-top: var(--sat); }
    59	.safe-bottom { padding-bottom: max(var(--sab), 8px); }
    60	.safe-left { padding-left: var(--sal); }
    61	.safe-right { padding-right: var(--sar); }
    62	
    63	/* ═══════════════════════════════════════════════════
    64	   SCROLLBAR
    65	   ═══════════════════════════════════════════════════ */
    66	
    67	::-webkit-scrollbar { width: 4px; height: 4px; }
    68	::-webkit-scrollbar-track { background: transparent; }
    69	::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 4px; }
    70	::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.15); }
    71	
    72	/* ═══════════════════════════════════════════════════
    73	   ANIMATIONS
    74	   ═══════════════════════════════════════════════════ */
    75	
    76	@keyframes fadeIn {
    77	  from { opacity: 0; transform: translateY(6px); }
    78	  to   { opacity: 1; transform: translateY(0); }
    79	}
    80	
    81	@keyframes slideInLeft {
    82	  from { transform: translateX(-100%); }
    83	  to   { transform: translateX(0); }
    84	}
    85	
    86	@keyframes slideOutLeft {
    87	  from { transform: translateX(0); }
    88	  to   { transform: translateX(-100%); }
    89	}
    90	
    91	@keyframes fadeOverlayIn {
    92	  from { opacity: 0; }
    93	  to   { opacity: 1; }
    94	}
    95	
    96	@keyframes fadeOverlayOut {
    97	  from { opacity: 1; }
    98	  to   { opacity: 0; }
    99	}
   100	
   101	.animate-fade-in { animation: fadeIn 0.2s ease-out both; }
   102	.animate-slide-in { animation: slideInLeft 0.25s cubic-bezier(0.16, 1, 0.3, 1) both; }
   103	.animate-slide-out { animation: slideOutLeft 0.2s ease-in both; }
   104	.animate-overlay-in { animation: fadeOverlayIn 0.2s ease-out both; }
   105	.animate-overlay-out { animation: fadeOverlayOut 0.15s ease-in both; }
   106	
   107	/* ═══════════════════════════════════════════════════
   108	   MOBILE INPUT FIXES
   109	   ═══════════════════════════════════════════════════ */
   110	
   111	textarea, input, select {
   112	  font-size: 16px !important; /* Prevents iOS zoom on focus */
   113	}
   114	
   115	@media (min-width: 640px) {
   116	  textarea, input, select {
   117	    font-size: 14px !important;
   118	  }
   119	}
   120	
   121	textarea {
   122	  -webkit-appearance: none;
   123	  appearance: none;
   124	}
   125	
   126	select {
   127	  -webkit-appearance: none;
   128	  appearance: none;
   129	  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23666' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E");
   130	  background-repeat: no-repeat;
   131	  background-position: right 10px center;
   132	  padding-right: 28px;
   133	}
   134	
   135	/* ═══════════════════════════════════════════════════
   136	   TOUCH-FRIENDLY RANGE SLIDER
   137	   ═══════════════════════════════════════════════════ */
   138	
   139	input[type="range"] {
   140	  -webkit-appearance: none;
   141	  appearance: none;
   142	  width: 100%;
   143	  height: 6px;
   144	  border-radius: 3px;
   145	  background: rgba(255,255,255,0.08);
   146	  outline: none;
   147	  cursor: pointer;
   148	}
   149	
   150	input[type="range"]::-webkit-slider-thumb {
   151	  -webkit-appearance: none;
   152	  appearance: none;
   153	  width: 22px;
   154	  height: 22px;
   155	  border-radius: 50%;
   156	  background: #e53e3e;
   157	  border: 2px solid #1a1a2e;
   158	  cursor: pointer;
   159	  box-shadow: 0 0 8px rgba(229,62,62,0.3);
   160	}
   161	
   162	input[type="range"]::-moz-range-thumb {
   163	  width: 22px;
   164	  height: 22px;
   165	  border-radius: 50%;
   166	  background: #e53e3e;
   167	  border: 2px solid #1a1a2e;
   168	  cursor: pointer;
   169	}
   170	
   171	/* ═══════════════════════════════════════════════════
   172	   MARKDOWN PROSE
   173	   ═══════════════════════════════════════════════════ */
   174	
   175	.prose-anton {
   176	  color: #e2e2ea;
   177	  line-height: 1.65;
   178	  word-break: break-word;
   179	  overflow-wrap: anywhere;
   180	}
   181	
   182	.prose-anton h1, .prose-anton h2, .prose-anton h3,
   183	.prose-anton h4, .prose-anton h5, .prose-anton h6 {
   184	  color: #fff;
   185	  font-weight: 600;
   186	  margin-top: 1.2em;
   187	  margin-bottom: 0.5em;
   188	}
   189	
   190	.prose-anton h1 { font-size: 1.4em; }
   191	.prose-anton h2 { font-size: 1.2em; }
   192	.prose-anton h3 { font-size: 1.05em; }
   193	
   194	.prose-anton p { margin-bottom: 0.75em; }
   195	
   196	.prose-anton ul, .prose-anton ol {
   197	  padding-left: 1.4em;
   198	  margin-bottom: 0.75em;
   199	}
   200	
   201	.prose-anton li { margin-bottom: 0.25em; }
   202	.prose-anton li::marker { color: #555; }
   203	
   204	.prose-anton code:not(pre code) {
   205	  background: rgba(255,255,255,0.06);
   206	  border: 1px solid rgba(255,255,255,0.08);
   207	  border-radius: 4px;
   208	  padding: 0.15em 0.35em;
   209	  font-family: 'JetBrains Mono', monospace;
   210	  font-size: 0.85em;
   211	  color: #ff6b6b;
   212	  word-break: break-all;
   213	}
   214	
   215	.prose-anton a {
   216	  color: #e53e3e;
   217	  text-decoration: underline;
   218	  text-underline-offset: 2px;
   219	}
   220	
   221	.prose-anton blockquote {
   222	  border-left: 3px solid #e53e3e;
   223	  padding: 0.5em 1em;
   224	  margin: 0.75em 0;
   225	  background: rgba(229,62,62,0.04);
   226	  border-radius: 0 6px 6px 0;
   227	  color: #aaa;
   228	}
   229	
   230	.prose-anton hr {
   231	  border: none;
   232	  border-top: 1px solid rgba(255,255,255,0.08);
   233	  margin: 1.5em 0;
   234	}
   235	
   236	.prose-anton table {
   237	  width: 100%;
   238	  border-collapse: collapse;
   239	  font-size: 0.85em;
   240	  margin: 0.75em 0;
   241	  display: block;
   242	  overflow-x: auto;
   243	  -webkit-overflow-scrolling: touch;
   244	}
   245	
   246	.prose-anton th, .prose-anton td {
   247	  border: 1px solid rgba(255,255,255,0.08);
   248	  padding: 0.4em 0.7em;
   249	  text-align: left;
   250	  white-space: nowrap;
   251	}
   252	
   253	.prose-anton th {
   254	  background: rgba(255,255,255,0.04);
   255	  font-weight: 600;
   256	}
   257	
   258	.prose-anton strong { color: #fff; font-weight: 600; }
   259	.prose-anton em { font-style: italic; }
   260	
   261	/* ═══════════════════════════════════════════════════
   262	   THINKING PULSE
   263	   ═══════════════════════════════════════════════════ */
   264	
   265	@keyframes thinkPulse {
   266	  0%, 100% { opacity: 1; }
   267	  50% { opacity: 0.5; }
   268	}
   269	
   270	.thinking-pulse { animation: thinkPulse 1.5s ease-in-out infinite; }
   271	
   272	/* ═══════════════════════════════════════════════════
   273	   MOBILE-SPECIFIC OVERRIDES
   274	   ═══════════════════════════════════════════════════ */
   275	
   276	@media (max-width: 639px) {
   277	  .prose-anton { font-size: 0.9rem; line-height: 1.6; }
   278	  .prose-anton h1 { font-size: 1.25em; }
   279	  .prose-anton h2 { font-size: 1.15em; }
   280	}
   281	
   282	/* Prevent body scroll when modal/drawer is open */
   283	body.drawer-open {
   284	  touch-action: none;
   285	}│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [056]: frontend/src/index.css


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [057/69]: frontend/src/main.jsx
│ LANGUAGE: jsx | LINES: 15 | SIZE: 409 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	import React from "react";
     2	import ReactDOM from "react-dom/client";
     3	import { BrowserRouter } from "react-router-dom";
     4	import { AppProvider } from "./store";
     5	import App from "./App";
     6	import "./index.css";
     7	
     8	ReactDOM.createRoot(document.getElementById("root")).render(
     9	  <React.StrictMode>
    10	    <BrowserRouter>
    11	      <AppProvider>
    12	        <App />
    13	      </AppProvider>
    14	    </BrowserRouter>
    15	  </React.StrictMode>
    16	);│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [057]: frontend/src/main.jsx


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [058/69]: frontend/src/pages/AdminPage.jsx
│ LANGUAGE: jsx | LINES: 299 | SIZE: 18204 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	import React, { useState, useEffect, useCallback } from "react";
     2	import { useApp } from "../store";
     3	import {
     4	  adminStats, adminListUsers, adminCreateUser, adminUpdateUser, adminDeleteUser,
     5	  adminListChats, adminGetAppSettings, adminUpdateAppSettings,
     6	  adminGetPermissionDefaults, adminUpdatePermissionDefaults, adminApplyDefaults,
     7	  adminGetUserPermissions, adminUpdateUserPermissions,
     8	} from "../api";
     9	import {
    10	  Users, MessageSquare, Brain, Database, Shield, Plus, Trash2, Edit, Check, X,
    11	  BarChart3, Settings2, ChevronDown, ChevronRight, ToggleLeft, ToggleRight, UserPlus,
    12	  RefreshCw, Lock, Unlock, Save,
    13	} from "lucide-react";
    14	
    15	export default function AdminPage() {
    16	  const { state } = useApp();
    17	  const [tab, setTab] = useState("stats");
    18	  const [stats, setStats] = useState(null);
    19	  const [users, setUsers] = useState([]);
    20	  const [chats, setChats] = useState([]);
    21	  const [appSettings, setAppSettings] = useState(null);
    22	  const [loading, setLoading] = useState(false);
    23	  const [error, setError] = useState("");
    24	  const [success, setSuccess] = useState("");
    25	
    26	  // Create user form
    27	  const [showCreate, setShowCreate] = useState(false);
    28	  const [newUser, setNewUser] = useState({ username: "", email: "", password: "", role: "user", quota_tokens_monthly: 2000000 });
    29	
    30	  // Edit user
    31	  const [editUserId, setEditUserId] = useState(null);
    32	  const [editData, setEditData] = useState({});
    33	
    34	  // Permissions
    35	  const [permUserId, setPermUserId] = useState(null);
    36	  const [permData, setPermData] = useState(null);
    37	  const [defaults, setDefaults] = useState(null);
    38	
    39	  const token = state.token;
    40	
    41	  const load = useCallback(async () => {
    42	    setLoading(true);
    43	    setError("");
    44	    try {
    45	      const [s, u, c, as_] = await Promise.all([
    46	        adminStats(token), adminListUsers(token), adminListChats(token), adminGetAppSettings(token),
    47	      ]);
    48	      setStats(s); setUsers(u); setChats(c); setAppSettings(as_);
    49	    } catch (e) { setError(e.message); } finally { setLoading(false); }
    50	  }, [token]);
    51	
    52	  useEffect(() => { load(); }, [load]);
    53	
    54	  function flash(msg) { setSuccess(msg); setTimeout(() => setSuccess(""), 3000); }
    55	
    56	  async function toggleRegistration() {
    57	    try {
    58	      const res = await adminUpdateAppSettings(token, { registration_enabled: !appSettings.registration_enabled });
    59	      setAppSettings(res);
    60	      flash(res.registration_enabled ? "Registration ENABLED" : "Registration DISABLED");
    61	    } catch (e) { setError(e.message); }
    62	  }
    63	
    64	  async function handleCreateUser(e) {
    65	    e.preventDefault();
    66	    try {
    67	      await adminCreateUser(token, newUser);
    68	      setShowCreate(false); setNewUser({ username: "", email: "", password: "", role: "user", quota_tokens_monthly: 2000000 });
    69	      flash("User created"); load();
    70	    } catch (e) { setError(e.message); }
    71	  }
    72	
    73	  async function handleUpdateUser(userId) {
    74	    try {
    75	      await adminUpdateUser(token, userId, editData);
    76	      setEditUserId(null); flash("User updated"); load();
    77	    } catch (e) { setError(e.message); }
    78	  }
    79	
    80	  async function handleDeleteUser(userId, username) {
    81	    if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
    82	    try { await adminDeleteUser(token, userId); flash("User deleted"); load(); } catch (e) { setError(e.message); }
    83	  }
    84	
    85	  async function handleToggleActive(userId, currentActive) {
    86	    try { await adminUpdateUser(token, userId, { is_active: !currentActive }); flash("User updated"); load(); } catch (e) { setError(e.message); }
    87	  }
    88	
    89	  async function openPermissions(userId) {
    90	    try {
    91	      const [p, d] = await Promise.all([adminGetUserPermissions(token, userId), adminGetPermissionDefaults(token)]);
    92	      setPermUserId(userId); setPermData(p); setDefaults(d);
    93	    } catch (e) { setError(e.message); }
    94	  }
    95	
    96	  async function savePermissions() {
    97	    try { await adminUpdateUserPermissions(token, permUserId, permData); setPermUserId(null); flash("Permissions saved"); } catch (e) { setError(e.message); }
    98	  }
    99	
   100	  const TABS = [
   101	    { id: "stats", label: "Dashboard", icon: BarChart3 },
   102	    { id: "users", label: "Users", icon: Users },
   103	    { id: "settings", label: "Settings", icon: Settings2 },
   104	  ];
   105	
   106	  return (
   107	    <div className="flex-1 flex flex-col min-h-0 bg-anton-bg">
   108	      {/* Header */}
   109	      <div className="border-b border-anton-border bg-anton-surface px-6 py-4">
   110	        <div className="flex items-center justify-between">
   111	          <div>
   112	            <h1 className="text-xl font-bold text-white flex items-center gap-2"><Shield size={20} className="text-anton-accent" /> Admin Dashboard</h1>
   113	            <p className="text-xs text-anton-muted mt-0.5">Superadmin Control Panel</p>
   114	          </div>
   115	          <button onClick={load} className="text-anton-muted hover:text-white transition p-2 rounded-lg hover:bg-anton-card"><RefreshCw size={16} /></button>
   116	        </div>
   117	        <div className="flex gap-1 mt-4">
   118	          {TABS.map((t) => (
   119	            <button key={t.id} onClick={() => setTab(t.id)}
   120	              className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition ${tab === t.id ? "bg-anton-accent text-white" : "text-anton-muted hover:text-white hover:bg-anton-card"}`}>
   121	              <t.icon size={14} /> {t.label}
   122	            </button>
   123	          ))}
   124	        </div>
   125	      </div>
   126	
   127	      {/* Notifications */}
   128	      {error && <div className="mx-6 mt-3 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm flex items-center justify-between">{error} <button onClick={() => setError("")}><X size={14} /></button></div>}
   129	      {success && <div className="mx-6 mt-3 p-3 bg-green-500/10 border border-green-500/30 rounded-lg text-green-400 text-sm">{success}</div>}
   130	
   131	      {/* Content */}
   132	      <div className="flex-1 overflow-y-auto p-6 space-y-6">
   133	        {loading && !stats && <div className="text-center text-anton-muted py-12">Loading...</div>}
   134	
   135	        {/* ═══ STATS TAB ═══ */}
   136	        {tab === "stats" && stats && (
   137	          <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
   138	            {[
   139	              { label: "Users", value: stats.total_users, icon: Users, color: "text-blue-400" },
   140	              { label: "Active", value: stats.active_users, icon: Check, color: "text-green-400" },
   141	              { label: "Chats", value: stats.total_chats, icon: MessageSquare, color: "text-purple-400" },
   142	              { label: "Messages", value: stats.total_messages, icon: MessageSquare, color: "text-cyan-400" },
   143	              { label: "Tokens Used", value: (stats.total_tokens_used / 1000000).toFixed(1) + "M", icon: Brain, color: "text-yellow-400" },
   144	              { label: "Knowledge", value: stats.total_knowledge_bases, icon: Database, color: "text-orange-400" },
   145	            ].map((s, i) => (
   146	              <div key={i} className="bg-anton-card border border-anton-border rounded-xl p-4">
   147	                <div className="flex items-center gap-2 mb-2"><s.icon size={14} className={s.color} /><span className="text-xs text-anton-muted">{s.label}</span></div>
   148	                <div className="text-2xl font-bold text-white">{typeof s.value === "number" ? s.value.toLocaleString() : s.value}</div>
   149	              </div>
   150	            ))}
   151	          </div>
   152	        )}
   153	
   154	        {/* ═══ USERS TAB ═══ */}
   155	        {tab === "users" && (
   156	          <>
   157	            <div className="flex items-center justify-between">
   158	              <h2 className="text-lg font-semibold text-white">Users ({users.length})</h2>
   159	              <button onClick={() => setShowCreate(!showCreate)} className="flex items-center gap-1.5 px-4 py-2 bg-anton-accent text-white rounded-lg text-sm hover:opacity-90 transition">
   160	                <Plus size={14} /> Create User
   161	              </button>
   162	            </div>
   163	
   164	            {showCreate && (
   165	              <form onSubmit={handleCreateUser} className="bg-anton-card border border-anton-border rounded-xl p-4 space-y-3 animate-fade-in">
   166	                <div className="grid grid-cols-2 gap-3">
   167	                  <input value={newUser.username} onChange={(e) => setNewUser({ ...newUser, username: e.target.value })} placeholder="Username" required
   168	                    className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" />
   169	                  <input value={newUser.email} onChange={(e) => setNewUser({ ...newUser, email: e.target.value })} placeholder="Email" required type="email"
   170	                    className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" />
   171	                  <input value={newUser.password} onChange={(e) => setNewUser({ ...newUser, password: e.target.value })} placeholder="Password" required type="password"
   172	                    className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" />
   173	                  <select value={newUser.role} onChange={(e) => setNewUser({ ...newUser, role: e.target.value })}
   174	                    className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent">
   175	                    <option value="user">User</option><option value="admin">Admin</option>
   176	                  </select>
   177	                </div>
   178	                <div className="flex gap-2">
   179	                  <button type="submit" className="px-4 py-2 bg-anton-accent text-white rounded-lg text-sm">Create</button>
   180	                  <button type="button" onClick={() => setShowCreate(false)} className="px-4 py-2 bg-anton-card border border-anton-border text-white rounded-lg text-sm">Cancel</button>
   181	                </div>
   182	              </form>
   183	            )}
   184	
   185	            <div className="space-y-2">
   186	              {users.map((u) => (
   187	                <div key={u.id} className="bg-anton-card border border-anton-border rounded-xl p-4 flex items-center gap-4">
   188	                  <div className="flex-1 min-w-0">
   189	                    <div className="flex items-center gap-2">
   190	                      <span className="text-white font-medium text-sm">{u.username}</span>
   191	                      <span className={`text-[10px] px-1.5 py-0.5 rounded ${u.role === "superadmin" ? "bg-red-500/20 text-red-400" : u.role === "admin" ? "bg-yellow-500/20 text-yellow-400" : "bg-blue-500/20 text-blue-400"}`}>{u.role}</span>
   192	                      {!u.is_active && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-500/20 text-gray-400">disabled</span>}
   193	                    </div>
   194	                    <div className="text-xs text-anton-muted mt-0.5">{u.email} • {u.chat_count} chats • {((u.tokens_used_this_month || 0) / 1000000).toFixed(2)}M / {((u.quota_tokens_monthly || 0) / 1000000).toFixed(0)}M tokens</div>
   195	                  </div>
   196	                  {u.role !== "superadmin" && (
   197	                    <div className="flex items-center gap-1">
   198	                      <button onClick={() => handleToggleActive(u.id, u.is_active)} title={u.is_active ? "Disable" : "Enable"}
   199	                        className="p-1.5 rounded-lg hover:bg-anton-bg transition text-anton-muted hover:text-white">
   200	                        {u.is_active ? <Unlock size={14} /> : <Lock size={14} />}
   201	                      </button>
   202	                      <button onClick={() => openPermissions(u.id)} title="Permissions"
   203	                        className="p-1.5 rounded-lg hover:bg-anton-bg transition text-anton-muted hover:text-purple-400">
   204	                        <Shield size={14} />
   205	                      </button>
   206	                      <button onClick={() => handleDeleteUser(u.id, u.username)} title="Delete"
   207	                        className="p-1.5 rounded-lg hover:bg-anton-bg transition text-anton-muted hover:text-red-400">
   208	                        <Trash2 size={14} />
   209	                      </button>
   210	                    </div>
   211	                  )}
   212	                </div>
   213	              ))}
   214	            </div>
   215	
   216	            {/* Permissions Modal */}
   217	            {permUserId && permData && (
   218	              <div className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4" onClick={() => setPermUserId(null)}>
   219	                <div className="bg-anton-card border border-anton-border rounded-2xl p-6 w-full max-w-lg max-h-[80vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
   220	                  <h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2"><Shield size={18} className="text-purple-400" /> User Permissions</h3>
   221	                  <div className="space-y-3">
   222	                    {["can_use_web_search", "can_use_ui_design", "can_use_knowledge_base", "can_use_gitlab", "can_use_attachments", "can_export_pptx", "can_export_docx"].map((key) => (
   223	                      <label key={key} className="flex items-center justify-between">
   224	                        <span className="text-sm text-white">{key.replace(/^can_/, "").replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}</span>
   225	                        <button onClick={() => setPermData({ ...permData, [key]: !permData[key] })}
   226	                          className={`transition ${permData[key] ? "text-green-400" : "text-anton-muted"}`}>
   227	                          {permData[key] ? <ToggleRight size={24} /> : <ToggleLeft size={24} />}
   228	                        </button>
   229	                      </label>
   230	                    ))}
   231	                    <div className="border-t border-anton-border pt-3 space-y-3">
   232	                      <div><label className="text-xs text-anton-muted">Allowed Models</label>
   233	                        <input value={permData.allowed_models || ""} onChange={(e) => setPermData({ ...permData, allowed_models: e.target.value })}
   234	                          className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm mt-1 focus:outline-none focus:border-anton-accent" /></div>
   235	                      {["max_tokens_cap", "max_reasoning_budget", "max_chats", "max_messages_per_day", "max_knowledge_bases", "max_documents_per_kb", "max_attachment_size_mb", "max_attachments_per_message"].map((key) => (
   236	                        <div key={key}><label className="text-xs text-anton-muted">{key.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())} {key.includes("max_") && permData[key] === 0 ? "(unlimited)" : ""}</label>
   237	                          <input type="number" value={permData[key] || 0} onChange={(e) => setPermData({ ...permData, [key]: parseInt(e.target.value) || 0 })}
   238	                            className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm mt-1 focus:outline-none focus:border-anton-accent" /></div>
   239	                      ))}
   240	                    </div>
   241	                  </div>
   242	                  <div className="flex gap-2 mt-4">
   243	                    <button onClick={savePermissions} className="flex items-center gap-1.5 px-4 py-2 bg-anton-accent text-white rounded-lg text-sm"><Save size={14} /> Save</button>
   244	                    <button onClick={() => setPermUserId(null)} className="px-4 py-2 bg-anton-card border border-anton-border text-white rounded-lg text-sm">Cancel</button>
   245	                  </div>
   246	                </div>
   247	              </div>
   248	            )}
   249	          </>
   250	        )}
   251	
   252	        {/* ═══ SETTINGS TAB ═══ */}
   253	        {tab === "settings" && (
   254	          <div className="space-y-6">
   255	            {/* Registration Toggle */}
   256	            <div className="bg-anton-card border border-anton-border rounded-xl p-6">
   257	              <h3 className="text-lg font-semibold text-white mb-1 flex items-center gap-2"><UserPlus size={18} className="text-blue-400" /> User Registration</h3>
   258	              <p className="text-xs text-anton-muted mb-4">Control whether new users can register accounts themselves.</p>
   259	              {appSettings && (
   260	                <div className="flex items-center justify-between bg-anton-bg border border-anton-border rounded-xl p-4">
   261	                  <div>
   262	                    <div className="text-sm text-white font-medium">
   263	                      Registration is {appSettings.registration_enabled ? <span className="text-green-400">ENABLED</span> : <span className="text-red-400">DISABLED</span>}
   264	                    </div>
   265	                    <div className="text-xs text-anton-muted mt-0.5">
   266	                      {appSettings.registration_enabled
   267	                        ? "Anyone can create a new account from the login page."
   268	                        : "Only superadmins can create new user accounts."}
   269	                    </div>
   270	                  </div>
   271	                  <button onClick={toggleRegistration}
   272	                    className={`transition-colors ${appSettings.registration_enabled ? "text-green-400 hover:text-green-300" : "text-anton-muted hover:text-white"}`}>
   273	                    {appSettings.registration_enabled ? <ToggleRight size={36} /> : <ToggleLeft size={36} />}
   274	                  </button>
   275	                </div>
   276	              )}
   277	            </div>
   278	
   279	            {/* Recent Chats */}
   280	            <div className="bg-anton-card border border-anton-border rounded-xl p-6">
   281	              <h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2"><MessageSquare size={18} className="text-purple-400" /> Recent Chats</h3>
   282	              <div className="space-y-2 max-h-80 overflow-y-auto">
   283	                {chats.slice(0, 50).map((c) => (
   284	                  <div key={c.id} className="flex items-center justify-between bg-anton-bg border border-anton-border rounded-lg p-3">
   285	                    <div className="min-w-0">
   286	                      <div className="text-sm text-white truncate">{c.title}</div>
   287	                      <div className="text-xs text-anton-muted">{c.username} • {c.message_count} msgs</div>
   288	                    </div>
   289	                    <span className="text-[10px] text-anton-muted shrink-0">{new Date(c.updated_at).toLocaleDateString()}</span>
   290	                  </div>
   291	                ))}
   292	                {chats.length === 0 && <div className="text-center text-anton-muted text-sm py-4">No chats yet</div>}
   293	              </div>
   294	            </div>
   295	          </div>
   296	        )}
   297	      </div>
   298	    </div>
   299	  );
   300	}│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [058]: frontend/src/pages/AdminPage.jsx


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [059/69]: frontend/src/pages/ChatPage.jsx
│ LANGUAGE: jsx | LINES: 100 | SIZE: 4013 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	import React, { useEffect } from "react";
     2	import { useApp } from "../store";
     3	import { listChats, createChat } from "../api";
     4	import Sidebar from "../components/Sidebar";
     5	import ChatView from "../components/ChatView";
     6	import { Flame, Menu, Plus, MessageSquare } from "lucide-react";
     7	
     8	export default function ChatPage() {
     9	  const { state, dispatch } = useApp();
    10	
    11	  useEffect(() => {
    12	    (async () => {
    13	      try {
    14	        const chats = await listChats(state.token);
    15	        dispatch({ type: "SET_CHATS", chats });
    16	        if (!state.activeChatId && chats.length > 0) {
    17	          dispatch({ type: "SET_ACTIVE_CHAT", chatId: chats[0].id });
    18	        }
    19	      } catch { /* ignore */ }
    20	    })();
    21	  }, [state.token]);
    22	
    23	  async function handleNewChat() {
    24	    try {
    25	      const chat = await createChat(state.token);
    26	      dispatch({ type: "ADD_CHAT", chat });
    27	    } catch { /* ignore */ }
    28	  }
    29	
    30	  return (
    31	    <div className="h-full h-dvh flex overflow-hidden bg-anton-bg">
    32	      {/* Desktop sidebar — always visible */}
    33	      <div className="hidden sm:flex">
    34	        <Sidebar />
    35	      </div>
    36	
    37	      {/* Mobile sidebar overlay */}
    38	      {state.sidebarOpen && (
    39	        <>
    40	          <div
    41	            className="sm:hidden fixed inset-0 z-40 bg-black/60 animate-overlay-in"
    42	            onClick={() => dispatch({ type: "SET_SIDEBAR_OPEN", open: false })}
    43	          />
    44	          <div className="sm:hidden fixed inset-y-0 left-0 z-50 w-[280px] animate-slide-in safe-top safe-bottom">
    45	            <Sidebar mobile onClose={() => dispatch({ type: "SET_SIDEBAR_OPEN", open: false })} />
    46	          </div>
    47	        </>
    48	      )}
    49	
    50	      {/* Main content */}
    51	      <div className="flex-1 flex flex-col min-w-0">
    52	        {/* Mobile header */}
    53	        <div className="sm:hidden flex items-center gap-2 px-3 py-2.5 border-b border-anton-border bg-anton-surface safe-top">
    54	          <button
    55	            onClick={() => dispatch({ type: "TOGGLE_SIDEBAR" })}
    56	            className="p-2 -ml-1 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition active:scale-95"
    57	          >
    58	            <Menu size={20} />
    59	          </button>
    60	          <div className="flex-1 min-w-0 flex items-center gap-2">
    61	            <div className="w-6 h-6 rounded-md bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shrink-0">
    62	              <Flame size={12} className="text-white" />
    63	            </div>
    64	            <span className="text-sm font-medium text-white truncate">
    65	              {state.chats.find((c) => c.id === state.activeChatId)?.title || "Son of Anton"}
    66	            </span>
    67	          </div>
    68	          <button
    69	            onClick={handleNewChat}
    70	            className="p-2 -mr-1 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition active:scale-95"
    71	          >
    72	            <Plus size={20} />
    73	          </button>
    74	        </div>
    75	
    76	        {/* Chat or empty state */}
    77	        {state.activeChatId ? (
    78	          <ChatView key={state.activeChatId} chatId={state.activeChatId} />
    79	        ) : (
    80	          <div className="flex-1 flex items-center justify-center p-6">
    81	            <div className="text-center max-w-sm">
    82	              <div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20">
    83	                <Flame size={32} className="text-white" />
    84	              </div>
    85	              <h2 className="text-xl font-bold text-white mb-2">Son of Anton</h2>
    86	              <p className="text-anton-muted text-sm mb-6">
    87	                Avatar of All Elements of Code
    88	              </p>
    89	              <button
    90	                onClick={handleNewChat}
    91	                className="inline-flex items-center gap-2 px-5 py-3 bg-anton-accent text-white rounded-xl font-medium hover:opacity-90 transition active:scale-95"
    92	              >
    93	                <MessageSquare size={18} /> Start a conversation
    94	              </button>
    95	            </div>
    96	          </div>
    97	        )}
    98	      </div>
    99	    </div>
   100	  );
   101	}│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [059]: frontend/src/pages/ChatPage.jsx


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [060/69]: frontend/src/pages/GitLabPage.jsx
│ LANGUAGE: jsx | LINES: 320 | SIZE: 20635 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	import React, { useState, useEffect } from "react";
     2	import { useApp } from "../store";
     3	import { useNavigate } from "react-router-dom";
     4	import {
     5	    gitlabGetSettings, gitlabUpdateSettings, gitlabTestConnection,
     6	    gitlabSearchProjects, gitlabCreateProject, gitlabListRepos,
     7	    gitlabLinkRepo, gitlabUnlinkRepo, gitlabGetTree, gitlabGetFile,
     8	    gitlabListActions, gitlabApproveAction, gitlabRejectAction,
     9	} from "../api";
    10	import {
    11	    ArrowLeft, Settings2, Plug, Check, X, Search, Plus, Link, Unlink,
    12	    FolderTree, GitBranch, Eye, Shield, Clock, CheckCircle, XCircle,
    13	    Loader2, ExternalLink, FileText, Folder, RefreshCw,
    14	} from "lucide-react";
    15	
    16	export default function GitLabPage() {
    17	    const { state } = useApp();
    18	    const nav = useNavigate();
    19	    const t = state.token;
    20	
    21	    const [tab, setTab] = useState("connection");
    22	    const [settings, setSettings] = useState({ gitlab_url: "", gitlab_token: "", is_active: false });
    23	    const [url, setUrl] = useState("");
    24	    const [token, setToken] = useState("");
    25	    const [testResult, setTestResult] = useState(null);
    26	    const [testing, setTesting] = useState(false);
    27	    const [saving, setSaving] = useState(false);
    28	
    29	    const [projects, setProjects] = useState([]);
    30	    const [searchQ, setSearchQ] = useState("");
    31	    const [searching, setSearching] = useState(false);
    32	    const [repos, setRepos] = useState([]);
    33	    const [linking, setLinking] = useState(null);
    34	
    35	    const [actions, setActions] = useState([]);
    36	    const [actionsTab, setActionsTab] = useState("pending");
    37	    const [processingAction, setProcessingAction] = useState(null);
    38	
    39	    const [browseRepo, setBrowseRepo] = useState(null);
    40	    const [tree, setTree] = useState([]);
    41	    const [fileContent, setFileContent] = useState(null);
    42	
    43	    useEffect(() => {
    44	        if (state.user?.role !== "superadmin") { nav("/"); return; }
    45	        loadSettings();
    46	        loadRepos();
    47	    }, []);
    48	
    49	    async function loadSettings() {
    50	        try { const s = await gitlabGetSettings(t); setSettings(s); setUrl(s.gitlab_url || ""); } catch { }
    51	    }
    52	    async function loadRepos() {
    53	        try { setRepos(await gitlabListRepos(t)); } catch { }
    54	    }
    55	    async function loadActions() {
    56	        try { setActions(await gitlabListActions(t, actionsTab)); } catch { }
    57	    }
    58	    useEffect(() => { if (tab === "actions") loadActions(); }, [tab, actionsTab]);
    59	
    60	    async function handleSave() {
    61	        setSaving(true);
    62	        try {
    63	            await gitlabUpdateSettings(t, { gitlab_url: url, gitlab_token: token || "UNCHANGED" });
    64	            await loadSettings();
    65	            setTestResult(null);
    66	        } catch (e) { alert(e.message); }
    67	        setSaving(false);
    68	    }
    69	
    70	    async function handleTest() {
    71	        setTesting(true); setTestResult(null);
    72	        try {
    73	            const r = await gitlabTestConnection(t);
    74	            setTestResult({ ok: true, msg: `Connected as ${r.name} (@${r.username})` });
    75	        } catch (e) { setTestResult({ ok: false, msg: e.message }); }
    76	        setTesting(false);
    77	    }
    78	
    79	    async function handleSearch() {
    80	        setSearching(true);
    81	        try { setProjects(await gitlabSearchProjects(t, searchQ, false)); } catch { }
    82	        setSearching(false);
    83	    }
    84	
    85	    async function handleLink(projectId) {
    86	        setLinking(projectId);
    87	        try { await gitlabLinkRepo(t, projectId); await loadRepos(); } catch (e) { alert(e.message); }
    88	        setLinking(null);
    89	    }
    90	
    91	    async function handleUnlink(repoId) {
    92	        if (!confirm("Unlink this repo?")) return;
    93	        try { await gitlabUnlinkRepo(t, repoId); await loadRepos(); } catch { }
    94	    }
    95	
    96	    async function handleBrowse(repo) {
    97	        setBrowseRepo(repo); setFileContent(null);
    98	        try {
    99	            const r = await gitlabGetTree(t, repo.id, "", null);
   100	            setTree(r.items || []);
   101	        } catch { setTree([]); }
   102	    }
   103	
   104	    async function handleViewFile(path) {
   105	        if (!browseRepo) return;
   106	        try {
   107	            const f = await gitlabGetFile(t, browseRepo.id, path, null);
   108	            setFileContent(f);
   109	        } catch (e) { setFileContent({ file_path: path, content: `Error: ${e.message}` }); }
   110	    }
   111	
   112	    async function handleApprove(id) {
   113	        setProcessingAction(id);
   114	        try { await gitlabApproveAction(t, id); await loadActions(); } catch (e) { alert(e.message); }
   115	        setProcessingAction(null);
   116	    }
   117	
   118	    async function handleReject(id) {
   119	        setProcessingAction(id);
   120	        try { await gitlabRejectAction(t, id); await loadActions(); } catch (e) { alert(e.message); }
   121	        setProcessingAction(null);
   122	    }
   123	
   124	    const linked = new Set(repos.map(r => r.gitlab_project_id));
   125	
   126	    return (
   127	        <div className="h-dvh flex flex-col bg-anton-bg text-anton-text">
   128	            {/* Header */}
   129	            <div className="border-b border-anton-border bg-anton-surface px-4 py-3 flex items-center gap-3">
   130	                <button onClick={() => nav("/")} className="text-anton-muted hover:text-white"><ArrowLeft size={20} /></button>
   131	                <div className="flex items-center gap-2">
   132	                    <GitBranch size={20} className="text-orange-400" />
   133	                    <h1 className="text-lg font-bold text-white">GitLab Command Center</h1>
   134	                </div>
   135	                <div className={`ml-auto flex items-center gap-1.5 text-xs ${settings.is_active ? "text-green-400" : "text-red-400"}`}>
   136	                    <div className={`w-2 h-2 rounded-full ${settings.is_active ? "bg-green-400" : "bg-red-400"}`} />
   137	                    {settings.is_active ? "Connected" : "Disconnected"}
   138	                </div>
   139	            </div>
   140	
   141	            {/* Tabs */}
   142	            <div className="border-b border-anton-border bg-anton-surface flex gap-0.5 px-4">
   143	                {[["connection", "Connection", Plug], ["repos", "Repositories", FolderTree], ["actions", "Actions", Shield]].map(([key, label, Icon]) => (
   144	                    <button key={key} onClick={() => setTab(key)}
   145	                        className={`flex items-center gap-1.5 px-4 py-2.5 text-sm border-b-2 transition ${tab === key ? "border-anton-accent text-white" : "border-transparent text-anton-muted hover:text-white"}`}>
   146	                        <Icon size={14} />{label}
   147	                    </button>
   148	                ))}
   149	            </div>
   150	
   151	            {/* Content */}
   152	            <div className="flex-1 overflow-y-auto p-4 space-y-4">
   153	                {/* ── CONNECTION TAB ── */}
   154	                {tab === "connection" && (
   155	                    <div className="max-w-2xl mx-auto space-y-4">
   156	                        <div className="bg-anton-card border border-anton-border rounded-xl p-5 space-y-4">
   157	                            <h2 className="text-white font-semibold flex items-center gap-2"><Settings2 size={16} className="text-anton-accent" /> Connection Settings</h2>
   158	                            <div>
   159	                                <label className="text-xs text-anton-muted block mb-1">GitLab URL</label>
   160	                                <input value={url} onChange={e => setUrl(e.target.value)} placeholder="https://gitlab.example.com"
   161	                                    className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent" />
   162	                            </div>
   163	                            <div>
   164	                                <label className="text-xs text-anton-muted block mb-1">Personal Access Token</label>
   165	                                <input type="password" value={token} onChange={e => setToken(e.target.value)} placeholder={settings.gitlab_token_set ? "••••••• (saved)" : "glpat-..."}
   166	                                    className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent" />
   167	                                <p className="text-[10px] text-anton-muted mt-1">Needs api, read_repository, write_repository scopes</p>
   168	                            </div>
   169	                            <div className="flex gap-2">
   170	                                <button onClick={handleSave} disabled={saving} className="px-4 py-2 bg-anton-accent text-white rounded-lg hover:opacity-80 transition disabled:opacity-50 flex items-center gap-1.5">
   171	                                    {saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} Save
   172	                                </button>
   173	                                <button onClick={handleTest} disabled={testing} className="px-4 py-2 bg-anton-card border border-anton-border text-white rounded-lg hover:border-anton-accent transition disabled:opacity-50 flex items-center gap-1.5">
   174	                                    {testing ? <Loader2 size={14} className="animate-spin" /> : <Plug size={14} />} Test
   175	                                </button>
   176	                            </div>
   177	                            {testResult && (
   178	                                <div className={`p-3 rounded-lg text-sm ${testResult.ok ? "bg-green-500/10 text-green-400 border border-green-500/30" : "bg-red-500/10 text-red-400 border border-red-500/30"}`}>
   179	                                    {testResult.ok ? <CheckCircle size={14} className="inline mr-1" /> : <XCircle size={14} className="inline mr-1" />}
   180	                                    {testResult.msg}
   181	                                </div>
   182	                            )}
   183	                        </div>
   184	                    </div>
   185	                )}
   186	
   187	                {/* ── REPOS TAB ── */}
   188	                {tab === "repos" && (
   189	                    <div className="max-w-4xl mx-auto space-y-4">
   190	                        {/* Search & Link */}
   191	                        <div className="bg-anton-card border border-anton-border rounded-xl p-4 space-y-3">
   192	                            <h2 className="text-white font-semibold flex items-center gap-2"><Search size={16} className="text-anton-accent" /> Find & Link Projects</h2>
   193	                            <div className="flex gap-2">
   194	                                <input value={searchQ} onChange={e => setSearchQ(e.target.value)} onKeyDown={e => e.key === "Enter" && handleSearch()} placeholder="Search GitLab projects..."
   195	                                    className="flex-1 bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" />
   196	                                <button onClick={handleSearch} disabled={searching} className="px-4 py-2 bg-anton-accent text-white rounded-lg text-sm hover:opacity-80 disabled:opacity-50">
   197	                                    {searching ? <Loader2 size={14} className="animate-spin" /> : "Search"}
   198	                                </button>
   199	                            </div>
   200	                            {projects.length > 0 && (
   201	                                <div className="max-h-60 overflow-y-auto space-y-1">
   202	                                    {projects.map(p => (
   203	                                        <div key={p.id} className="flex items-center justify-between bg-anton-bg rounded-lg px-3 py-2">
   204	                                            <div className="min-w-0">
   205	                                                <div className="text-sm text-white truncate">{p.path_with_namespace}</div>
   206	                                                <div className="text-[10px] text-anton-muted truncate">{p.description || "No description"}</div>
   207	                                            </div>
   208	                                            {linked.has(p.id) ? (
   209	                                                <span className="text-xs text-green-400 shrink-0 ml-2">✓ Linked</span>
   210	                                            ) : (
   211	                                                <button onClick={() => handleLink(p.id)} disabled={linking === p.id} className="text-xs bg-anton-accent/20 text-anton-accent px-2.5 py-1 rounded hover:bg-anton-accent/30 shrink-0 ml-2">
   212	                                                    {linking === p.id ? <Loader2 size={12} className="animate-spin" /> : <><Link size={12} className="inline mr-1" />Link</>}
   213	                                                </button>
   214	                                            )}
   215	                                        </div>
   216	                                    ))}
   217	                                </div>
   218	                            )}
   219	                        </div>
   220	
   221	                        {/* Linked Repos */}
   222	                        <div className="bg-anton-card border border-anton-border rounded-xl p-4 space-y-3">
   223	                            <div className="flex items-center justify-between">
   224	                                <h2 className="text-white font-semibold flex items-center gap-2"><FolderTree size={16} className="text-green-400" /> Linked Repositories ({repos.length})</h2>
   225	                                <button onClick={loadRepos} className="text-anton-muted hover:text-white"><RefreshCw size={14} /></button>
   226	                            </div>
   227	                            {repos.length === 0 && <p className="text-anton-muted text-sm">No repos linked yet. Search above.</p>}
   228	                            {repos.map(r => (
   229	                                <div key={r.id} className="bg-anton-bg rounded-xl p-3 space-y-2">
   230	                                    <div className="flex items-center justify-between">
   231	                                        <div>
   232	                                            <div className="text-sm text-white font-medium">{r.name}</div>
   233	                                            <div className="text-[10px] text-anton-muted">{r.path_with_namespace} • {r.default_branch}</div>
   234	                                        </div>
   235	                                        <div className="flex gap-1.5">
   236	                                            {r.web_url && <a href={r.web_url} target="_blank" rel="noopener noreferrer" className="p-1.5 text-anton-muted hover:text-white"><ExternalLink size={14} /></a>}
   237	                                            <button onClick={() => handleBrowse(r)} className="p-1.5 text-anton-muted hover:text-green-400"><Eye size={14} /></button>
   238	                                            <button onClick={() => handleUnlink(r.id)} className="p-1.5 text-anton-muted hover:text-red-400"><Unlink size={14} /></button>
   239	                                        </div>
   240	                                    </div>
   241	                                </div>
   242	                            ))}
   243	                        </div>
   244	
   245	                        {/* File Browser */}
   246	                        {browseRepo && (
   247	                            <div className="bg-anton-card border border-anton-border rounded-xl p-4 space-y-3">
   248	                                <div className="flex items-center justify-between">
   249	                                    <h2 className="text-white font-semibold text-sm">📂 {browseRepo.name} / {browseRepo.default_branch}</h2>
   250	                                    <button onClick={() => { setBrowseRepo(null); setFileContent(null); }} className="text-anton-muted hover:text-white"><X size={14} /></button>
   251	                                </div>
   252	                                <div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-[60vh]">
   253	                                    <div className="overflow-y-auto border border-anton-border rounded-lg p-2 space-y-0.5 max-h-[55vh]">
   254	                                        {tree.map(item => (
   255	                                            <button key={item.path} onClick={() => item.type === "blob" && handleViewFile(item.path)}
   256	                                                className={`w-full text-left px-2 py-1 rounded text-xs flex items-center gap-1.5 ${item.type === "blob" ? "hover:bg-anton-accent/10 text-white cursor-pointer" : "text-anton-muted cursor-default"}`}>
   257	                                                {item.type === "tree" ? <Folder size={12} className="text-blue-400 shrink-0" /> : <FileText size={12} className="text-anton-muted shrink-0" />}
   258	                                                <span className="truncate">{item.path}</span>
   259	                                            </button>
   260	                                        ))}
   261	                                    </div>
   262	                                    <div className="overflow-y-auto border border-anton-border rounded-lg max-h-[55vh]">
   263	                                        {fileContent ? (
   264	                                            <div>
   265	                                                <div className="sticky top-0 bg-anton-surface px-3 py-1.5 border-b border-anton-border text-xs text-anton-muted">{fileContent.file_path}</div>
   266	                                                <pre className="p-3 text-[11px] text-white whitespace-pre-wrap font-mono leading-relaxed">{fileContent.content}</pre>
   267	                                            </div>
   268	                                        ) : (
   269	                                            <div className="flex items-center justify-center h-full text-anton-muted text-sm p-4">Click a file to view</div>
   270	                                        )}
   271	                                    </div>
   272	                                </div>
   273	                            </div>
   274	                        )}
   275	                    </div>
   276	                )}
   277	
   278	                {/* ── ACTIONS TAB ── */}
   279	                {tab === "actions" && (
   280	                    <div className="max-w-3xl mx-auto space-y-4">
   281	                        <div className="flex gap-2 mb-2">
   282	                            {["pending", "approved", "rejected"].map(s => (
   283	                                <button key={s} onClick={() => setActionsTab(s)}
   284	                                    className={`px-3 py-1.5 rounded-lg text-xs capitalize ${actionsTab === s ? "bg-anton-accent text-white" : "bg-anton-card text-anton-muted border border-anton-border hover:text-white"}`}>
   285	                                    {s}
   286	                                </button>
   287	                            ))}
   288	                            <button onClick={loadActions} className="ml-auto text-anton-muted hover:text-white"><RefreshCw size={14} /></button>
   289	                        </div>
   290	                        {actions.length === 0 && <p className="text-anton-muted text-sm text-center py-8">No {actionsTab} actions</p>}
   291	                        {actions.map(a => (
   292	                            <div key={a.id} className="bg-anton-card border border-anton-border rounded-xl p-4 space-y-2">
   293	                                <div className="flex items-center justify-between">
   294	                                    <div>
   295	                                        <span className="text-xs bg-anton-accent/20 text-anton-accent px-2 py-0.5 rounded mr-2">{a.action_type}</span>
   296	                                        <span className="text-sm text-white">{a.title || "Untitled"}</span>
   297	                                    </div>
   298	                                    <span className="text-[10px] text-anton-muted">{a.repo_name}</span>
   299	                                </div>
   300	                                <pre className="text-[10px] text-anton-muted bg-anton-bg rounded p-2 max-h-32 overflow-y-auto">{a.payload}</pre>
   301	                                {a.status === "pending" && (
   302	                                    <div className="flex gap-2">
   303	                                        <button onClick={() => handleApprove(a.id)} disabled={processingAction === a.id}
   304	                                            className="px-3 py-1.5 bg-green-600 text-white rounded text-xs hover:bg-green-500 disabled:opacity-50 flex items-center gap-1">
   305	                                            {processingAction === a.id ? <Loader2 size={12} className="animate-spin" /> : <CheckCircle size={12} />} Approve
   306	                                        </button>
   307	                                        <button onClick={() => handleReject(a.id)} disabled={processingAction === a.id}
   308	                                            className="px-3 py-1.5 bg-red-600 text-white rounded text-xs hover:bg-red-500 disabled:opacity-50 flex items-center gap-1">
   309	                                            <XCircle size={12} /> Reject
   310	                                        </button>
   311	                                    </div>
   312	                                )}
   313	                                {a.result_message && <p className="text-[10px] text-anton-muted">{a.result_message}</p>}
   314	                            </div>
   315	                        ))}
   316	                    </div>
   317	                )}
   318	            </div>
   319	        </div>
   320	    );
   321	}│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [060]: frontend/src/pages/GitLabPage.jsx


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [061/69]: frontend/src/pages/KnowledgePage.jsx
│ LANGUAGE: jsx | LINES: 384 | SIZE: 20241 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	import React, { useState, useEffect, useRef } from "react";
     2	import { useApp } from "../store";
     3	import { useNavigate } from "react-router-dom";
     4	import {
     5	    listKnowledgeBases,
     6	    createKnowledgeBase,
     7	    getKnowledgeBase,
     8	    updateKnowledgeBase,
     9	    deleteKnowledgeBase,
    10	    deleteKnowledgeDocument,
    11	    uploadDocuments,
    12	} from "../api";
    13	import {
    14	    BookOpen, Plus, Trash2, FileText, Upload, ArrowLeft, Edit3,
    15	    Check, X, ChevronRight, Database, Hash, Type, Calendar,
    16	    AlertTriangle, Loader2, Search, Flame, RefreshCw,
    17	} from "lucide-react";
    18	
    19	function fmtSize(b) {
    20	    if (!b) return "0 B";
    21	    if (b < 1024) return b + " B";
    22	    if (b < 1048576) return (b / 1024).toFixed(1) + " KB";
    23	    return (b / 1048576).toFixed(1) + " MB";
    24	}
    25	
    26	function fmtDate(s) {
    27	    if (!s) return "";
    28	    try {
    29	        return new Date(s).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
    30	    } catch {
    31	        return s;
    32	    }
    33	}
    34	
    35	export default function KnowledgePage() {
    36	    const { state } = useApp();
    37	    const navigate = useNavigate();
    38	    const token = state.token;
    39	
    40	    const [kbs, setKbs] = useState([]);
    41	    const [selectedKb, setSelectedKb] = useState(null);
    42	    const [loading, setLoading] = useState(true);
    43	    const [error, setError] = useState("");
    44	
    45	    // Create KB state
    46	    const [showCreate, setShowCreate] = useState(false);
    47	    const [newName, setNewName] = useState("");
    48	    const [newDesc, setNewDesc] = useState("");
    49	    const [creating, setCreating] = useState(false);
    50	
    51	    // Edit KB state
    52	    const [editing, setEditing] = useState(false);
    53	    const [editName, setEditName] = useState("");
    54	    const [editDesc, setEditDesc] = useState("");
    55	    const [saving, setSaving] = useState(false);
    56	
    57	    // Upload state
    58	    const [uploading, setUploading] = useState(false);
    59	    const [uploadResult, setUploadResult] = useState(null);
    60	    const fileRef = useRef(null);
    61	
    62	    // Delete doc state
    63	    const [deletingDocId, setDeletingDocId] = useState(null);
    64	    const [confirmDeleteKb, setConfirmDeleteKb] = useState(false);
    65	
    66	    // Search
    67	    const [docSearch, setDocSearch] = useState("");
    68	
    69	    useEffect(() => {
    70	        loadKbs();
    71	    }, []);
    72	
    73	    async function loadKbs() {
    74	        setLoading(true);
    75	        setError("");
    76	        try {
    77	            const data = await listKnowledgeBases(token);
    78	            setKbs(data);
    79	        } catch (e) {
    80	            setError(e.message);
    81	        } finally {
    82	            setLoading(false);
    83	        }
    84	    }
    85	
    86	    async function loadKbDetail(kbId) {
    87	        setError("");
    88	        try {
    89	            const data = await getKnowledgeBase(token, kbId);
    90	            setSelectedKb(data);
    91	        } catch (e) {
    92	            setError(e.message);
    93	        }
    94	    }
    95	
    96	    async function handleCreate() {
    97	        if (!newName.trim()) return;
    98	        setCreating(true);
    99	        setError("");
   100	        try {
   101	            await createKnowledgeBase(token, newName.trim(), newDesc.trim());
   102	            setNewName("");
   103	            setNewDesc("");
   104	            setShowCreate(false);
   105	            await loadKbs();
   106	        } catch (e) {
   107	            setError(e.message);
   108	        } finally {
   109	            setCreating(false);
   110	        }
   111	    }
   112	
   113	    async function handleDeleteKb() {
   114	        if (!selectedKb) return;
   115	        setError("");
   116	        try {
   117	            await deleteKnowledgeBase(token, selectedKb.id);
   118	            setSelectedKb(null);
   119	            setConfirmDeleteKb(false);
   120	            await loadKbs();
   121	        } catch (e) {
   122	            setError(e.message);
   123	        }
   124	    }
   125	
   126	    async function handleSaveEdit() {
   127	        if (!selectedKb) return;
   128	        setSaving(true);
   129	        setError("");
   130	        try {
   131	            await updateKnowledgeBase(token, selectedKb.id, {
   132	                name: editName.trim() || selectedKb.name,
   133	                description: editDesc.trim(),
   134	            });
   135	            setEditing(false);
   136	            await loadKbDetail(selectedKb.id);
   137	            await loadKbs();
   138	        } catch (e) {
   139	            setError(e.message);
   140	        } finally {
   141	            setSaving(false);
   142	        }
   143	    }
   144	
   145	    async function handleUpload(e) {
   146	        const files = Array.from(e.target.files || []);
   147	        if (!files.length || !selectedKb) return;
   148	        e.target.value = "";
   149	        setUploading(true);
   150	        setUploadResult(null);
   151	        setError("");
   152	        try {
   153	            const result = await uploadDocuments(token, selectedKb.id, files);
   154	            setUploadResult(result);
   155	            await loadKbDetail(selectedKb.id);
   156	            await loadKbs();
   157	        } catch (err) {
   158	            setError(err.message);
   159	        } finally {
   160	            setUploading(false);
   161	        }
   162	    }
   163	
   164	    async function handleDeleteDoc(docId) {
   165	        if (!selectedKb) return;
   166	        setDeletingDocId(docId);
   167	        setError("");
   168	        try {
   169	            await deleteKnowledgeDocument(token, selectedKb.id, docId);
   170	            await loadKbDetail(selectedKb.id);
   171	            await loadKbs();
   172	        } catch (e) {
   173	            setError(e.message);
   174	        } finally {
   175	            setDeletingDocId(null);
   176	        }
   177	    }
   178	
   179	    function openKb(kb) {
   180	        setSelectedKb(null);
   181	        setEditing(false);
   182	        setUploadResult(null);
   183	        setDocSearch("");
   184	        setConfirmDeleteKb(false);
   185	        loadKbDetail(kb.id);
   186	    }
   187	
   188	    function goBack() {
   189	        setSelectedKb(null);
   190	        setEditing(false);
   191	        setUploadResult(null);
   192	        setDocSearch("");
   193	        setConfirmDeleteKb(false);
   194	    }
   195	
   196	    const filteredDocs = (selectedKb?.documents || []).filter((d) =>
   197	        d.filename.toLowerCase().includes(docSearch.toLowerCase())
   198	    );
   199	
   200	    // ─── KB Detail View ───
   201	    if (selectedKb) {
   202	        return (
   203	            <div className="h-dvh flex flex-col bg-anton-bg text-anton-text">
   204	                {/* Header */}
   205	                <div className="border-b border-anton-border bg-anton-surface px-4 py-3 flex items-center gap-3">
   206	                    <button onClick={goBack} className="text-anton-muted hover:text-white transition"><ArrowLeft size={20} /></button>
   207	                    <div className="flex-1 min-w-0">
   208	                        {editing ? (
   209	                            <div className="flex items-center gap-2">
   210	                                <input value={editName} onChange={(e) => setEditName(e.target.value)} className="bg-anton-card border border-anton-border rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-anton-accent flex-1" placeholder="Name" />
   211	                                <button onClick={handleSaveEdit} disabled={saving} className="p-1.5 rounded-lg bg-anton-accent text-white hover:opacity-80 transition disabled:opacity-50">{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}</button>
   212	                                <button onClick={() => setEditing(false)} className="p-1.5 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition"><X size={14} /></button>
   213	                            </div>
   214	                        ) : (
   215	                            <div className="flex items-center gap-2">
   216	                                <h1 className="text-lg font-bold text-white truncate">{selectedKb.name}</h1>
   217	                                <button onClick={() => { setEditName(selectedKb.name); setEditDesc(selectedKb.description || ""); setEditing(true); }} className="text-anton-muted hover:text-anton-accent transition"><Edit3 size={14} /></button>
   218	                            </div>
   219	                        )}
   220	                        {!editing && selectedKb.description && <p className="text-xs text-anton-muted truncate mt-0.5">{selectedKb.description}</p>}
   221	                    </div>
   222	                    <button onClick={() => navigate("/")} className="text-anton-muted hover:text-white transition text-sm">← Chat</button>
   223	                </div>
   224	
   225	                {/* Edit description row */}
   226	                {editing && (
   227	                    <div className="px-4 py-2 border-b border-anton-border bg-anton-surface">
   228	                        <textarea value={editDesc} onChange={(e) => setEditDesc(e.target.value)} placeholder="Description (optional)" rows={2} className="w-full bg-anton-card border border-anton-border rounded-lg px-3 py-2 text-white text-sm resize-none focus:outline-none focus:border-anton-accent" />
   229	                    </div>
   230	                )}
   231	
   232	                {error && <div className="px-4 py-2 bg-red-500/10 border-b border-red-500/30 text-red-400 text-sm flex items-center gap-2"><AlertTriangle size={14} />{error}</div>}
   233	
   234	                {/* Stats bar */}
   235	                <div className="px-4 py-2 border-b border-anton-border bg-anton-surface/50 flex items-center gap-4 text-xs text-anton-muted flex-wrap">
   236	                    <span className="flex items-center gap-1"><FileText size={12} />{selectedKb.document_count} docs</span>
   237	                    <span className="flex items-center gap-1"><Hash size={12} />{selectedKb.chunk_count} chunks</span>
   238	                    <span className="flex items-center gap-1"><Type size={12} />{(selectedKb.estimated_tokens || 0).toLocaleString()} est. tokens</span>
   239	                    <span className="flex items-center gap-1"><Calendar size={12} />{fmtDate(selectedKb.created_at)}</span>
   240	                </div>
   241	
   242	                {/* Actions bar */}
   243	                <div className="px-4 py-2 border-b border-anton-border bg-anton-surface/30 flex items-center gap-2 flex-wrap">
   244	                    <button onClick={() => fileRef.current?.click()} disabled={uploading} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-anton-accent text-white text-sm hover:opacity-80 transition disabled:opacity-50">
   245	                        {uploading ? <Loader2 size={14} className="animate-spin" /> : <Upload size={14} />}
   246	                        {uploading ? "Uploading…" : "Upload Files"}
   247	                    </button>
   248	                    <input ref={fileRef} type="file" multiple className="hidden" accept=".pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.gd,.dart,.vue,.svelte,.log,.doc,.docx" onChange={handleUpload} />
   249	                    <button onClick={() => loadKbDetail(selectedKb.id)} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-anton-border text-anton-muted text-sm hover:text-white hover:bg-anton-card transition"><RefreshCw size={14} /> Refresh</button>
   250	                    <div className="flex-1" />
   251	                    {confirmDeleteKb ? (
   252	                        <div className="flex items-center gap-2">
   253	                            <span className="text-red-400 text-xs">Delete entire KB?</span>
   254	                            <button onClick={handleDeleteKb} className="px-3 py-1.5 rounded-lg bg-red-600 text-white text-sm hover:opacity-80 transition">Yes, Delete</button>
   255	                            <button onClick={() => setConfirmDeleteKb(false)} className="px-3 py-1.5 rounded-lg border border-anton-border text-anton-muted text-sm hover:text-white transition">Cancel</button>
   256	                        </div>
   257	                    ) : (
   258	                        <button onClick={() => setConfirmDeleteKb(true)} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-red-500/30 text-red-400 text-sm hover:bg-red-500/10 transition"><Trash2 size={14} /> Delete KB</button>
   259	                    )}
   260	                </div>
   261	
   262	                {/* Upload result */}
   263	                {uploadResult && (
   264	                    <div className="px-4 py-2 border-b border-anton-border bg-green-500/5">
   265	                        <p className="text-xs text-green-400 font-semibold mb-1">Upload complete: {uploadResult.total_chunks_added} chunks from {uploadResult.total_files} file(s)</p>
   266	                        {uploadResult.files?.map((f, i) => (
   267	                            <p key={i} className={`text-xs ${f.error ? "text-red-400" : "text-anton-muted"}`}>
   268	                                {f.filename}: {f.error ? `Error: ${f.error}` : `${f.chunks_added} chunks, ~${f.estimated_tokens?.toLocaleString()} tokens`}
   269	                            </p>
   270	                        ))}
   271	                    </div>
   272	                )}
   273	
   274	                {/* Search */}
   275	                {(selectedKb.documents || []).length > 3 && (
   276	                    <div className="px-4 py-2 border-b border-anton-border">
   277	                        <div className="relative">
   278	                            <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-anton-muted" />
   279	                            <input value={docSearch} onChange={(e) => setDocSearch(e.target.value)} placeholder="Search documents…" className="w-full bg-anton-card border border-anton-border rounded-lg pl-9 pr-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" />
   280	                        </div>
   281	                    </div>
   282	                )}
   283	
   284	                {/* Document list */}
   285	                <div className="flex-1 overflow-y-auto px-4 py-3">
   286	                    {filteredDocs.length === 0 ? (
   287	                        <div className="text-center py-16">
   288	                            <Database size={40} className="mx-auto text-anton-muted mb-3 opacity-50" />
   289	                            <p className="text-anton-muted text-sm">{docSearch ? "No documents match your search." : "No documents yet. Upload some files!"}</p>
   290	                        </div>
   291	                    ) : (
   292	                        <div className="space-y-1.5">
   293	                            {filteredDocs.map((doc) => (
   294	                                <div key={doc.id} className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-anton-border bg-anton-card hover:border-anton-accent/30 transition group">
   295	                                    <FileText size={16} className="text-anton-accent shrink-0" />
   296	                                    <div className="flex-1 min-w-0">
   297	                                        <p className="text-sm text-white truncate font-medium">{doc.filename}</p>
   298	                                        <p className="text-[11px] text-anton-muted">{fmtSize(doc.file_size)} · {doc.chunk_count} chunks · {fmtDate(doc.created_at)}</p>
   299	                                    </div>
   300	                                    <button
   301	                                        onClick={() => handleDeleteDoc(doc.id)}
   302	                                        disabled={deletingDocId === doc.id}
   303	                                        className="p-1.5 rounded-lg text-anton-muted hover:text-red-400 hover:bg-red-500/10 transition opacity-0 group-hover:opacity-100 disabled:opacity-50"
   304	                                        title="Remove document and its vector chunks"
   305	                                    >
   306	                                        {deletingDocId === doc.id ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />}
   307	                                    </button>
   308	                                </div>
   309	                            ))}
   310	                        </div>
   311	                    )}
   312	                </div>
   313	            </div>
   314	        );
   315	    }
   316	
   317	    // ─── KB List View ───
   318	    return (
   319	        <div className="h-dvh flex flex-col bg-anton-bg text-anton-text">
   320	            {/* Header */}
   321	            <div className="border-b border-anton-border bg-anton-surface px-4 py-3 flex items-center gap-3">
   322	                <button onClick={() => navigate("/")} className="text-anton-muted hover:text-white transition"><ArrowLeft size={20} /></button>
   323	                <div className="w-9 h-9 rounded-xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center">
   324	                    <Flame size={18} className="text-white" />
   325	                </div>
   326	                <div className="flex-1">
   327	                    <h1 className="text-lg font-bold text-white">Knowledge Bases</h1>
   328	                    <p className="text-xs text-anton-muted">Manage your RAG document collections</p>
   329	                </div>
   330	                <button onClick={() => setShowCreate(!showCreate)} className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition ${showCreate ? "bg-anton-card text-anton-muted" : "bg-anton-accent text-white hover:opacity-80"}`}>
   331	                    {showCreate ? <X size={14} /> : <Plus size={14} />}
   332	                    {showCreate ? "Cancel" : "New KB"}
   333	                </button>
   334	            </div>
   335	
   336	            {error && <div className="px-4 py-2 bg-red-500/10 border-b border-red-500/30 text-red-400 text-sm flex items-center gap-2"><AlertTriangle size={14} />{error}</div>}
   337	
   338	            {/* Create form */}
   339	            {showCreate && (
   340	                <div className="px-4 py-3 border-b border-anton-border bg-anton-surface/50 space-y-2 animate-fade-in">
   341	                    <input value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="Knowledge base name" className="w-full bg-anton-card border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" onKeyDown={(e) => e.key === "Enter" && handleCreate()} />
   342	                    <textarea value={newDesc} onChange={(e) => setNewDesc(e.target.value)} placeholder="Description (optional)" rows={2} className="w-full bg-anton-card border border-anton-border rounded-lg px-3 py-2 text-white text-sm resize-none focus:outline-none focus:border-anton-accent" />
   343	                    <button onClick={handleCreate} disabled={!newName.trim() || creating} className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-anton-accent text-white text-sm hover:opacity-80 transition disabled:opacity-50">
   344	                        {creating ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />} Create
   345	                    </button>
   346	                </div>
   347	            )}
   348	
   349	            {/* KB list */}
   350	            <div className="flex-1 overflow-y-auto px-4 py-3">
   351	                {loading ? (
   352	                    <div className="flex items-center justify-center py-20"><Loader2 size={24} className="text-anton-accent animate-spin" /></div>
   353	                ) : kbs.length === 0 ? (
   354	                    <div className="text-center py-20">
   355	                        <BookOpen size={48} className="mx-auto text-anton-muted mb-4 opacity-50" />
   356	                        <p className="text-anton-muted text-sm">No knowledge bases yet.</p>
   357	                        <p className="text-anton-muted text-xs mt-1">Create one to start uploading documents for RAG.</p>
   358	                    </div>
   359	                ) : (
   360	                    <div className="space-y-2">
   361	                        {kbs.map((kb) => (
   362	                            <button
   363	                                key={kb.id}
   364	                                onClick={() => openKb(kb)}
   365	                                className="w-full text-left flex items-center gap-3 px-4 py-3 rounded-xl border border-anton-border bg-anton-card hover:border-anton-accent/40 hover:bg-anton-card/80 transition group"
   366	                            >
   367	                                <div className="w-10 h-10 rounded-lg bg-anton-accent/10 flex items-center justify-center shrink-0">
   368	                                    <BookOpen size={18} className="text-anton-accent" />
   369	                                </div>
   370	                                <div className="flex-1 min-w-0">
   371	                                    <p className="text-sm text-white font-semibold truncate">{kb.name}</p>
   372	                                    {kb.description && <p className="text-xs text-anton-muted truncate">{kb.description}</p>}
   373	                                    <p className="text-[11px] text-anton-muted mt-0.5">
   374	                                        {kb.document_count} docs · {kb.chunk_count} chunks · ~{(kb.estimated_tokens || 0).toLocaleString()} tokens
   375	                                    </p>
   376	                                </div>
   377	                                <ChevronRight size={16} className="text-anton-muted group-hover:text-anton-accent transition shrink-0" />
   378	                            </button>
   379	                        ))}
   380	                    </div>
   381	                )}
   382	            </div>
   383	        </div>
   384	    );
   385	}│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [061]: frontend/src/pages/KnowledgePage.jsx


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [062/69]: frontend/src/pages/LoginPage.jsx
│ LANGUAGE: jsx | LINES: 136 | SIZE: 6209 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	import React, { useState, useEffect } from "react";
     2	import { useApp } from "../store";
     3	import { login, register, getRegistrationStatus } from "../api";
     4	import { Flame, LogIn, UserPlus, Eye, EyeOff, AlertCircle } from "lucide-react";
     5	
     6	export default function LoginPage() {
     7	  const { dispatch } = useApp();
     8	  const [isLogin, setIsLogin] = useState(true);
     9	  const [username, setUsername] = useState("");
    10	  const [email, setEmail] = useState("");
    11	  const [password, setPassword] = useState("");
    12	  const [showPassword, setShowPassword] = useState(false);
    13	  const [error, setError] = useState("");
    14	  const [loading, setLoading] = useState(false);
    15	  const [registrationEnabled, setRegistrationEnabled] = useState(true);
    16	  const [checkingRegistration, setCheckingRegistration] = useState(true);
    17	
    18	  useEffect(() => {
    19	    (async () => {
    20	      try {
    21	        const data = await getRegistrationStatus();
    22	        setRegistrationEnabled(data.registration_enabled);
    23	      } catch {
    24	        setRegistrationEnabled(true);
    25	      } finally {
    26	        setCheckingRegistration(false);
    27	      }
    28	    })();
    29	  }, []);
    30	
    31	  async function handleSubmit(e) {
    32	    e.preventDefault();
    33	    setError("");
    34	    setLoading(true);
    35	    try {
    36	      let data;
    37	      if (isLogin) {
    38	        data = await login(username, password);
    39	      } else {
    40	        data = await register(username, email, password);
    41	      }
    42	      dispatch({ type: "LOGIN", token: data.token, user: data.user });
    43	    } catch (err) {
    44	      setError(err.message || "Something went wrong");
    45	    } finally {
    46	      setLoading(false);
    47	    }
    48	  }
    49	
    50	  return (
    51	    <div className="min-h-screen bg-anton-bg flex items-center justify-center p-4">
    52	      <div className="w-full max-w-md">
    53	        <div className="text-center mb-8">
    54	          <div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20">
    55	            <Flame size={32} className="text-white" />
    56	          </div>
    57	          <h1 className="text-3xl font-bold text-white">Son of Anton</h1>
    58	          <p className="text-anton-muted mt-1 text-sm">Avatar of All Elements of Code</p>
    59	        </div>
    60	
    61	        <div className="bg-anton-card border border-anton-border rounded-2xl p-6 shadow-xl">
    62	          {!checkingRegistration && registrationEnabled && (
    63	            <div className="flex mb-6 bg-anton-bg rounded-xl p-1">
    64	              <button onClick={() => { setIsLogin(true); setError(""); }}
    65	                className={`flex-1 py-2 px-4 rounded-lg text-sm font-medium transition ${isLogin ? "bg-anton-accent text-white" : "text-anton-muted hover:text-white"}`}>
    66	                Sign In
    67	              </button>
    68	              <button onClick={() => { setIsLogin(false); setError(""); }}
    69	                className={`flex-1 py-2 px-4 rounded-lg text-sm font-medium transition ${!isLogin ? "bg-anton-accent text-white" : "text-anton-muted hover:text-white"}`}>
    70	                Register
    71	              </button>
    72	            </div>
    73	          )}
    74	
    75	          {error && (
    76	            <div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg flex items-center gap-2 text-red-400 text-sm">
    77	              <AlertCircle size={16} className="shrink-0" />
    78	              {error}
    79	            </div>
    80	          )}
    81	
    82	          <form onSubmit={handleSubmit} className="space-y-4">
    83	            <div>
    84	              <label className="text-xs text-anton-muted mb-1 block">Username</label>
    85	              <input type="text" value={username} onChange={(e) => setUsername(e.target.value)}
    86	                placeholder="Enter username" required autoFocus
    87	                className="w-full bg-anton-bg border border-anton-border rounded-xl px-4 py-3 text-white text-sm focus:outline-none focus:border-anton-accent transition" />
    88	            </div>
    89	
    90	            {!isLogin && (
    91	              <div>
    92	                <label className="text-xs text-anton-muted mb-1 block">Email</label>
    93	                <input type="email" value={email} onChange={(e) => setEmail(e.target.value)}
    94	                  placeholder="Enter email" required
    95	                  className="w-full bg-anton-bg border border-anton-border rounded-xl px-4 py-3 text-white text-sm focus:outline-none focus:border-anton-accent transition" />
    96	              </div>
    97	            )}
    98	
    99	            <div>
   100	              <label className="text-xs text-anton-muted mb-1 block">Password</label>
   101	              <div className="relative">
   102	                <input type={showPassword ? "text" : "password"} value={password} onChange={(e) => setPassword(e.target.value)}
   103	                  placeholder="Enter password" required
   104	                  className="w-full bg-anton-bg border border-anton-border rounded-xl px-4 py-3 pr-10 text-white text-sm focus:outline-none focus:border-anton-accent transition" />
   105	                <button type="button" onClick={() => setShowPassword(!showPassword)}
   106	                  className="absolute right-3 top-1/2 -translate-y-1/2 text-anton-muted hover:text-white transition">
   107	                  {showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
   108	                </button>
   109	              </div>
   110	            </div>
   111	
   112	            <button type="submit" disabled={loading}
   113	              className="w-full bg-anton-accent text-white rounded-xl py-3 font-medium hover:opacity-90 transition disabled:opacity-50 flex items-center justify-center gap-2">
   114	              {loading ? (
   115	                <span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
   116	              ) : isLogin ? (
   117	                <><LogIn size={18} /> Sign In</>
   118	              ) : (
   119	                <><UserPlus size={18} /> Create Account</>
   120	              )}
   121	            </button>
   122	          </form>
   123	
   124	          {!checkingRegistration && !registrationEnabled && (
   125	            <div className="mt-4 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg text-yellow-400 text-xs text-center">
   126	              Registration is currently disabled. Contact your administrator.
   127	            </div>
   128	          )}
   129	        </div>
   130	
   131	        <p className="text-center text-anton-muted text-xs mt-6">
   132	          Created by Mahmoud Aglan — AL-Arcade
   133	        </p>
   134	      </div>
   135	    </div>
   136	  );
   137	}│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [062]: frontend/src/pages/LoginPage.jsx


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [063/69]: frontend/src/store.jsx
│ LANGUAGE: jsx | LINES: 81 | SIZE: 3116 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	import React, { createContext, useContext, useReducer } from "react";
     2	
     3	const AppContext = createContext(null);
     4	
     5	const initialState = {
     6	  token: localStorage.getItem("token") || null,
     7	  user: null,
     8	  chats: [],
     9	  activeChatId: null,
    10	  chatMessages: {},
    11	  activeStreams: {},
    12	  sidebarOpen: false,
    13	};
    14	
    15	function reducer(state, action) {
    16	  switch (action.type) {
    17	    case "LOGIN":
    18	      localStorage.setItem("token", action.token);
    19	      return { ...state, token: action.token, user: action.user };
    20	    case "LOGOUT":
    21	      localStorage.removeItem("token");
    22	      return { ...initialState, token: null };
    23	    case "SET_USER":
    24	      return { ...state, user: action.user };
    25	
    26	    case "SET_CHATS":
    27	      return { ...state, chats: action.chats };
    28	    case "SET_ACTIVE_CHAT":
    29	      return { ...state, activeChatId: action.chatId, sidebarOpen: false };
    30	    case "ADD_CHAT":
    31	      return { ...state, chats: [action.chat, ...state.chats], activeChatId: action.chat.id, sidebarOpen: false };
    32	    case "UPDATE_CHAT":
    33	      return { ...state, chats: state.chats.map(c => c.id === action.chat.id ? { ...c, ...action.chat } : c) };
    34	    case "REMOVE_CHAT": {
    35	      const remaining = state.chats.filter(c => c.id !== action.chatId);
    36	      return { ...state, chats: remaining, activeChatId: state.activeChatId === action.chatId ? remaining[0]?.id || null : state.activeChatId };
    37	    }
    38	
    39	    case "SET_MESSAGES":
    40	      return { ...state, chatMessages: { ...state.chatMessages, [action.chatId]: action.messages } };
    41	    case "ADD_MESSAGE": {
    42	      const prev = state.chatMessages[action.chatId] || [];
    43	      return { ...state, chatMessages: { ...state.chatMessages, [action.chatId]: [...prev, action.message] } };
    44	    }
    45	
    46	    case "SET_STREAMING":
    47	      return { ...state, activeStreams: action.streaming ? { ...state.activeStreams, [action.chatId]: true } : Object.fromEntries(Object.entries(state.activeStreams).filter(([k]) => k !== action.chatId)) };
    48	
    49	    case "SET_SIDEBAR_OPEN":
    50	      return { ...state, sidebarOpen: action.open };
    51	    case "TOGGLE_SIDEBAR":
    52	      return { ...state, sidebarOpen: !state.sidebarOpen };
    53	
    54	    default:
    55	      return state;
    56	  }
    57	}
    58	
    59	export function AppProvider({ children }) {
    60	  const [state, dispatch] = useReducer(reducer, initialState);
    61	  return <AppContext.Provider value={{ state, dispatch }}>{children}</AppContext.Provider>;
    62	}
    63	
    64	export function useApp() {
    65	  const ctx = useContext(AppContext);
    66	  if (!ctx) throw new Error("useApp must be inside AppProvider");
    67	  return ctx;
    68	}
    69	
    70	/** Helper to get current user permissions with sane defaults */
    71	export function usePermissions() {
    72	  const { state } = useApp();
    73	  const p = state.user?.permissions;
    74	  if (!p) return {
    75	    can_use_web_search: false, can_use_ui_design: false, can_use_knowledge_base: true,
    76	    can_use_gitlab: false, can_use_attachments: true, can_export_pptx: true, can_export_docx: true,
    77	    allowed_models: "all", max_tokens_cap: 4096, max_reasoning_budget: 0, max_chats: 50,
    78	    max_messages_per_day: 100, max_knowledge_bases: 3, max_documents_per_kb: 20,
    79	    max_attachment_size_mb: 10, max_attachments_per_message: 5,
    80	  };
    81	  return p;
    82	}│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [063]: frontend/src/store.jsx


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [064/69]: frontend/src/streamManager.js
│ LANGUAGE: javascript | LINES: 158 | SIZE: 5635 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	import { streamMessage } from "./api";
     2	
     3	const _streams = new Map();
     4	const _listeners = new Map();
     5	let _dispatch = null;
     6	
     7	export function setDispatch(dispatch) {
     8	  _dispatch = dispatch;
     9	}
    10	
    11	export function getStreamData(chatId) {
    12	  const s = _streams.get(chatId);
    13	  if (!s) return { streaming: false, text: "", thinking: "", isThinking: false };
    14	  return { streaming: true, text: s.text, thinking: s.thinking, isThinking: s.isThinking };
    15	}
    16	
    17	export function isStreaming(chatId) {
    18	  return _streams.has(chatId);
    19	}
    20	
    21	export function subscribe(chatId, cb) {
    22	  if (!_listeners.has(chatId)) _listeners.set(chatId, new Set());
    23	  _listeners.get(chatId).add(cb);
    24	  return () => {
    25	    const s = _listeners.get(chatId);
    26	    if (s) { s.delete(cb); if (!s.size) _listeners.delete(chatId); }
    27	  };
    28	}
    29	
    30	function _notify(id) {
    31	  const s = _listeners.get(id);
    32	  if (s) s.forEach((cb) => cb());
    33	}
    34	
    35	export function abortStream(chatId) {
    36	  const s = _streams.get(chatId);
    37	  if (s) {
    38	    s.abortController.abort();
    39	    _streams.delete(chatId);
    40	    _notify(chatId);
    41	    if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: false });
    42	  }
    43	}
    44	
    45	export function startStream({ token, chatId, body }) {
    46	  if (_streams.has(chatId)) return;
    47	  const ac = new AbortController();
    48	  _streams.set(chatId, { text: "", thinking: "", isThinking: false, abortController: ac });
    49	  if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: true });
    50	  _notify(chatId);
    51	
    52	  (async () => {
    53	    const s = _streams.get(chatId);
    54	    if (!s) return;
    55	    let usage = {};
    56	    let msgId = "";
    57	    try {
    58	      for await (const evt of streamMessage(token, chatId, body, ac.signal)) {
    59	        if (ac.signal.aborted || !_streams.has(chatId)) break;
    60	        _handleEvent(chatId, s, evt, (u) => { usage = u; }, (id) => { msgId = id; });
    61	      }
    62	      if (!ac.signal.aborted && _dispatch) {
    63	        _dispatch({
    64	          type: "ADD_MESSAGE", chatId, message: {
    65	            id: msgId || `gen-${Date.now()}`, role: "assistant", content: s.text,
    66	            thinking_content: s.thinking || null, input_tokens: usage.input_tokens || 0,
    67	            output_tokens: usage.output_tokens || 0, created_at: new Date().toISOString(), attachments: [],
    68	          }
    69	        });
    70	      }
    71	    } catch (err) {
    72	      if (!ac.signal.aborted && _dispatch) {
    73	        _dispatch({
    74	          type: "ADD_MESSAGE", chatId, message: {
    75	            id: `err-${Date.now()}`, role: "assistant", content: `**Error:** ${err.message}`,
    76	            created_at: new Date().toISOString(), attachments: [],
    77	          }
    78	        });
    79	      }
    80	    } finally {
    81	      _streams.delete(chatId);
    82	      _notify(chatId);
    83	      if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: false });
    84	    }
    85	  })();
    86	}
    87	
    88	/**
    89	 * Reconnect to an ongoing background generation via GET /stream endpoint.
    90	 */
    91	export function reconnectStream({ token, chatId }) {
    92	  if (_streams.has(chatId)) return;
    93	  const ac = new AbortController();
    94	  _streams.set(chatId, { text: "", thinking: "", isThinking: false, abortController: ac });
    95	  if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: true });
    96	  _notify(chatId);
    97	
    98	  (async () => {
    99	    const s = _streams.get(chatId);
   100	    if (!s) return;
   101	    let usage = {};
   102	    let msgId = "";
   103	    try {
   104	      const res = await fetch(`/api/chats/${chatId}/stream`, {
   105	        headers: { Authorization: `Bearer ${token}` },
   106	        signal: ac.signal,
   107	      });
   108	      if (!res.ok) throw new Error("Reconnect failed");
   109	
   110	      const reader = res.body.getReader();
   111	      const decoder = new TextDecoder();
   112	      let buffer = "";
   113	      while (true) {
   114	        const { done, value } = await reader.read();
   115	        if (done) break;
   116	        buffer += decoder.decode(value, { stream: true });
   117	        const parts = buffer.split("\n\n");
   118	        buffer = parts.pop() || "";
   119	        for (const part of parts) {
   120	          const line = part.trim();
   121	          if (line.startsWith("data: ")) {
   122	            try {
   123	              const evt = JSON.parse(line.slice(6));
   124	              if (ac.signal.aborted || !_streams.has(chatId)) break;
   125	              _handleEvent(chatId, s, evt, (u) => { usage = u; }, (id) => { msgId = id; });
   126	            } catch { /* skip */ }
   127	          }
   128	        }
   129	      }
   130	      if (!ac.signal.aborted && s.text && _dispatch) {
   131	        _dispatch({
   132	          type: "ADD_MESSAGE", chatId, message: {
   133	            id: msgId || `gen-${Date.now()}`, role: "assistant", content: s.text,
   134	            thinking_content: s.thinking || null, input_tokens: usage.input_tokens || 0,
   135	            output_tokens: usage.output_tokens || 0, created_at: new Date().toISOString(), attachments: [],
   136	          }
   137	        });
   138	      }
   139	    } catch { /* reconnect failed, generation may be done */ }
   140	    finally {
   141	      _streams.delete(chatId);
   142	      _notify(chatId);
   143	      if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: false });
   144	    }
   145	  })();
   146	}
   147	
   148	function _handleEvent(chatId, s, evt, setUsage, setMsgId) {
   149	  switch (evt.type) {
   150	    case "thinking_start": s.isThinking = true; _notify(chatId); break;
   151	    case "thinking_delta": s.thinking += evt.content; _notify(chatId); break;
   152	    case "thinking_end": s.isThinking = false; _notify(chatId); break;
   153	    case "text_delta": s.text += evt.content; _notify(chatId); break;
   154	    case "usage": setUsage({ input_tokens: evt.input_tokens, output_tokens: evt.output_tokens }); break;
   155	    case "title_update": if (_dispatch) _dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, title: evt.title } }); break;
   156	    case "done": setMsgId(evt.message_id); break;
   157	    case "error": s.text += `\n\n**Error:** ${evt.message}`; _notify(chatId); break;
   158	  }
   159	}│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [064]: frontend/src/streamManager.js


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [065/69]: frontend/tailwind.config.js
│ LANGUAGE: javascript | LINES: 26 | SIZE: 714 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	/** @type {import('tailwindcss').Config} */
     2	export default {
     3	  content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
     4	  theme: {
     5	    extend: {
     6	      colors: {
     7	        "anton-bg": "#09090f",
     8	        "anton-surface": "#0f0f1a",
     9	        "anton-card": "#16162a",
    10	        "anton-border": "#1e1e3a",
    11	        "anton-text": "#e2e2ea",
    12	        "anton-muted": "#6b6b8a",
    13	        "anton-accent": "#e53e3e",
    14	        "anton-success": "#48bb78",
    15	        "anton-danger": "#e53e3e",
    16	      },
    17	      fontFamily: {
    18	        sans: ["Inter", "system-ui", "-apple-system", "sans-serif"],
    19	        mono: ["JetBrains Mono", "Fira Code", "monospace"],
    20	      },
    21	      screens: {
    22	        "xs": "400px",
    23	      },
    24	    },
    25	  },
    26	  plugins: [],
    27	};│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [065]: frontend/tailwind.config.js


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [066/69]: frontend/vite.config.js
│ LANGUAGE: javascript | LINES: 23 | SIZE: 596 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	import { defineConfig } from "vite";
     2	import react from "@vitejs/plugin-react";
     3	
     4	export default defineConfig({
     5	  plugins: [react()],
     6	  server: {
     7	    proxy: {
     8	      "/api": "http://localhost:80",
     9	    },
    10	  },
    11	  build: {
    12	    // Content-hash all chunks so browsers fetch new versions
    13	    rollupOptions: {
    14	      output: {
    15	        entryFileNames: "assets/[name]-[hash].js",
    16	        chunkFileNames: "assets/[name]-[hash].js",
    17	        assetFileNames: "assets/[name]-[hash].[ext]",
    18	      },
    19	    },
    20	    // Generate a manifest for cache-busting verification
    21	    manifest: true,
    22	    sourcemap: false,
    23	  },
    24	});│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [066]: frontend/vite.config.js


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [067/69]: main.py
│ LANGUAGE: python | LINES: 16 | SIZE: 528 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	# This is a sample Python script.
     2	
     3	# Press Shift+F10 to execute it or replace it with your code.
     4	# Press Double Shift to search everywhere for classes, files, tool windows, actions, and settings.
     5	
     6	
     7	def print_hi(name):
     8	    # Use a breakpoint in the code line below to debug your script.
     9	    print(f'Hi, {name}')  # Press Ctrl+F8 to toggle the breakpoint.
    10	
    11	
    12	# Press the green button in the gutter to run the script.
    13	if __name__ == '__main__':
    14	    print_hi('PyCharm')
    15	
    16	# See PyCharm help at https://www.jetbrains.com/help/pycharm/
│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [067]: main.py


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [068/69]: requirements.txt
│ LANGUAGE: plaintext | LINES: 14 | SIZE: 274 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	fastapi==0.115.6
     2	uvicorn[standard]==0.34.0
     3	sqlalchemy==2.0.36
     4	pyjwt==2.10.1
     5	passlib[bcrypt]==1.7.4
     6	bcrypt==4.0.1
     7	python-multipart==0.0.20
     8	httpx==0.28.1
     9	chromadb==0.6.3
    10	PyPDF2==3.0.1
    11	pydantic==2.10.4
    12	Pillow==11.1.0
    13	beautifulsoup4==4.12.3
    14	python-pptx==1.0.2
    15	python-docx==1.1.2│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [068]: requirements.txt


┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [069/69]: warmup.py
│ LANGUAGE: python | LINES: 6 | SIZE: 221 bytes
├──────────────────────────────────────────────────────────────────────────────
│
     1	import chromadb
     2	
     3	client = chromadb.Client()
     4	col = client.create_collection(name="warmup")
     5	col.add(documents=["warmup embedding model"], ids=["id0"])
     6	client.delete_collection(name="warmup")
     7	print("Embedding model cached.")│
└──────────────────────────────────────────────────────────────────────────────
   ✅ END OF [069]: warmup.py



################################################################################
#                                                                              #
#                     ✅ END OF COMPLETE CODEBASE DUMP                         #
#                                                                              #
#  Total Files:  69                                                         #
#  Total Lines:  17892                                                      #
#  Total Size:   687KB                                                      #
#  Generated:    2026-04-10 19:55:42                                        #
#                                                                              #
#  This file contains EVERYTHING: source code, configs, env vars, Docker,      #
#  CI/CD, build tools, package manifests, docs — the complete picture.          #
#                                                                              #
################################################################################
