5092 lines
241 KiB
Markdown
5092 lines
241 KiB
Markdown
# 🌐 WEB API & FRONTEND CODE SNAPSHOT
|
||
|
||
## File: `./modules/web_api/main.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/main.py
|
||
PROJECT: SCUD Orion AI (Unified Repository)
|
||
MODULE: web_api (Main Application Entry Point)
|
||
ROLE: Инициализация FastAPI приложения, подключение роутеров и статики.
|
||
===============================================================================
|
||
"""
|
||
|
||
# ANCHOR[APP_INIT_IMPORTS]
|
||
import os
|
||
import sys
|
||
import logging
|
||
|
||
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
if CURRENT_DIR not in sys.path:
|
||
sys.path.insert(0, CURRENT_DIR)
|
||
|
||
ROOT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, "../../"))
|
||
|
||
for p in [ROOT_DIR, CURRENT_DIR]:
|
||
if p not in sys.path:
|
||
sys.path.insert(0, p)
|
||
|
||
from fastapi import FastAPI, HTTPException
|
||
from fastapi.staticfiles import StaticFiles
|
||
from fastapi.responses import FileResponse, JSONResponse
|
||
from fastapi.exceptions import RequestValidationError
|
||
from routers.manual_absences import router as manual_absences_router
|
||
|
||
from routers.auth import router as auth_router
|
||
from routers.admin import router as admin_router
|
||
from routers.tasks import router as tasks_router
|
||
from routers.chat import router as chat_router
|
||
from routers.files import router as files_router
|
||
from routers.exceptions import router as exceptions_router
|
||
from routers.snapshots import router as snapshots_router
|
||
from routers.remote_workers import router as remote_workers_router
|
||
from routers.context import router as context_router
|
||
|
||
# ANCHOR[APP_CONFIG]
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||
handlers=[logging.StreamHandler()]
|
||
)
|
||
|
||
STATIC_DIR = os.path.join(CURRENT_DIR, "static")
|
||
|
||
app = FastAPI(title="SCUD Orion AI Context API", version="2.5")
|
||
|
||
if os.path.exists(STATIC_DIR):
|
||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||
|
||
@app.exception_handler(RequestValidationError)
|
||
async def validation_exception_handler(request, exc):
|
||
logging.error(f"❌ ОШИБКА ВАЛИДАЦИИ 422 НА {request.url}: {exc.errors()}")
|
||
return JSONResponse(
|
||
status_code=422,
|
||
content={"detail": exc.errors(), "body": str(exc)}
|
||
)
|
||
|
||
# ANCHOR[ROUTER_REGISTRATION]
|
||
app.include_router(auth_router)
|
||
app.include_router(admin_router)
|
||
app.include_router(tasks_router)
|
||
app.include_router(chat_router)
|
||
app.include_router(files_router)
|
||
app.include_router(exceptions_router)
|
||
app.include_router(snapshots_router)
|
||
app.include_router(remote_workers_router)
|
||
app.include_router(context_router)
|
||
app.include_router(manual_absences_router)
|
||
|
||
# ANCHOR[ROOT_STATIC_ROUTES]
|
||
@app.get("/")
|
||
def read_root():
|
||
index_path = os.path.join(STATIC_DIR, "index.html")
|
||
if os.path.exists(index_path):
|
||
return FileResponse(index_path)
|
||
raise HTTPException(status_code=404, detail="Frontend index.html not found")
|
||
|
||
@app.get("/favicon.ico")
|
||
async def favicon():
|
||
file_path = os.path.join(STATIC_DIR, "favicon.ico")
|
||
if os.path.exists(file_path):
|
||
return FileResponse(file_path)
|
||
raise HTTPException(status_code=404)
|
||
|
||
@app.get("/{file_path:path}")
|
||
def serve_static_fallback(file_path: str):
|
||
clean_path = file_path.lstrip("/")
|
||
|
||
# Жесткая блокировка скрытых файлов (.env, .git) и служебных форматов
|
||
forbidden_patterns = [".env", ".git", ".yml", ".yaml", ".json", ".sql", ".php", ".bak"]
|
||
if clean_path.startswith(".") or any(p in clean_path.lower() for p in forbidden_patterns):
|
||
raise HTTPException(status_code=403, detail="Access denied")
|
||
|
||
target = os.path.join(STATIC_DIR, clean_path)
|
||
|
||
if os.path.isfile(target):
|
||
if clean_path.endswith(".js"):
|
||
return FileResponse(target, media_type="application/javascript")
|
||
elif clean_path.endswith(".css"):
|
||
return FileResponse(target, media_type="text/css")
|
||
return FileResponse(target)
|
||
|
||
filename = os.path.basename(clean_path)
|
||
for root, _, files in os.walk(STATIC_DIR):
|
||
if filename in files:
|
||
full_path = os.path.join(root, filename)
|
||
media = "application/javascript" if filename.endswith(".js") else "text/css"
|
||
return FileResponse(full_path, media_type=media)
|
||
|
||
raise HTTPException(status_code=404, detail="File not found")
|
||
```
|
||
|
||
## File: `./modules/web_api/routers/chat.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/routers/chat.py
|
||
ROLE: Полнофункциональный роутер чата с извлечением текста из PDF и сканов,
|
||
поддержкой Function Calling, Fast-Path и оптического распознавания OCR.
|
||
===============================================================================
|
||
"""
|
||
|
||
import os
|
||
import shutil
|
||
import base64
|
||
import logging
|
||
from typing import Optional, List, Dict, Any
|
||
|
||
from fastapi import APIRouter, Header, HTTPException, UploadFile, File, Form
|
||
from pydantic import BaseModel
|
||
|
||
from llm.agent import process_chat_message
|
||
from config import BASE_DIR
|
||
|
||
logger = logging.getLogger("CHAT_API")
|
||
router = APIRouter(prefix="/api/v1", tags=["Chat"])
|
||
|
||
UPLOAD_TMP_DIR = os.path.join(BASE_DIR, "data", "uploads")
|
||
os.makedirs(UPLOAD_TMP_DIR, exist_ok=True)
|
||
|
||
|
||
class ChatMessageRequest(BaseModel):
|
||
message: str
|
||
session_id: Optional[str] = "web_session_main"
|
||
user_id: Optional[int] = 1
|
||
|
||
|
||
def resolve_user_id(authorization: Optional[str] = None, explicit_user_id: Optional[int] = None) -> int:
|
||
if explicit_user_id and explicit_user_id > 0:
|
||
return explicit_user_id
|
||
|
||
if authorization and authorization.startswith("Bearer "):
|
||
token = authorization.replace("Bearer ", "").strip()
|
||
if token.isdigit():
|
||
return int(token)
|
||
elif token.startswith("dev_token_"):
|
||
try:
|
||
return int(token.replace("dev_token_", ""))
|
||
except ValueError:
|
||
pass
|
||
return 1
|
||
|
||
|
||
@router.post("/chat")
|
||
async def chat_endpoint(payload: ChatMessageRequest, authorization: Optional[str] = Header(None)):
|
||
user_id = resolve_user_id(authorization, payload.user_id)
|
||
session_id = payload.session_id or "web_session_main"
|
||
user_msg = payload.message.strip()
|
||
|
||
if not user_msg:
|
||
raise HTTPException(status_code=400, detail="Пустое сообщение")
|
||
|
||
logger.info(f"Сообщение от user_id={user_id}, session_id={session_id}: {user_msg}")
|
||
|
||
reply_text, history, action_payload = process_chat_message(
|
||
user_id=user_id,
|
||
user_message=user_msg,
|
||
session_id=session_id
|
||
)
|
||
|
||
return {
|
||
"status": "success",
|
||
"user_id": user_id,
|
||
"session_id": session_id,
|
||
"response": reply_text,
|
||
"action_payload": action_payload
|
||
}
|
||
|
||
|
||
@router.post("/chat/upload")
|
||
async def chat_upload_endpoint(
|
||
file: UploadFile = File(...),
|
||
message: Optional[str] = Form(""),
|
||
session_id: Optional[str] = Form("web_session_main"),
|
||
authorization: Optional[str] = Header(None)
|
||
):
|
||
user_id = resolve_user_id(authorization, 1)
|
||
file_path = os.path.join(UPLOAD_TMP_DIR, file.filename)
|
||
|
||
with open(file_path, "wb") as buffer:
|
||
shutil.copyfileobj(file.file, buffer)
|
||
|
||
file_context = ""
|
||
image_b64 = None
|
||
fn_lower = file.filename.lower()
|
||
|
||
# 1. Текстовые форматы
|
||
if fn_lower.endswith((".txt", ".csv", ".log", ".md")):
|
||
try:
|
||
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
||
file_context = f.read(6000)
|
||
except Exception as e:
|
||
logger.warning(f"Не удалось прочитать текст: {e}")
|
||
|
||
# 2. Изображения (прямой OCR)
|
||
elif fn_lower.endswith((".png", ".jpg", ".jpeg", ".webp")):
|
||
try:
|
||
with open(file_path, "rb") as f:
|
||
image_b64 = base64.b64encode(f.read()).decode("utf-8")
|
||
except Exception as e:
|
||
logger.warning(f"Ошибка кодирования картинки в base64: {e}")
|
||
|
||
# 3. PDF документы (текстовый слой + рендеринг скана при необходимости)
|
||
elif fn_lower.endswith(".pdf"):
|
||
# Попытка извлечь встроенный текстовый слой
|
||
try:
|
||
import pypdf
|
||
reader = pypdf.PdfReader(file_path)
|
||
extracted = []
|
||
for page in reader.pages:
|
||
t = page.extract_text()
|
||
if t:
|
||
extracted.append(t)
|
||
file_context = "\n".join(extracted).strip()
|
||
except Exception:
|
||
pass
|
||
|
||
# Если текстового слоя мало (скан или фото документа), рендерим страницу в картинку для Vision OCR
|
||
if len(file_context) < 40:
|
||
try:
|
||
import fitz # PyMuPDF
|
||
doc = fitz.open(file_path)
|
||
if len(doc) > 0:
|
||
page = doc[0]
|
||
pix = page.get_pixmap(dpi=150)
|
||
img_bytes = pix.tobytes("png")
|
||
image_b64 = base64.b64encode(img_bytes).decode("utf-8")
|
||
file_context = ""
|
||
except Exception as e:
|
||
logger.warning(f"PyMuPDF не установлен или сбой рендеринга PDF: {e}")
|
||
|
||
user_msg = message.strip() or f"Распознай и проанализируй прикрепленный документ {file.filename}"
|
||
|
||
reply_text, history, action_payload = process_chat_message(
|
||
user_id=user_id,
|
||
user_message=user_msg,
|
||
file_context=file_context,
|
||
image_b64=image_b64,
|
||
session_id=session_id
|
||
)
|
||
|
||
return {
|
||
"status": "success",
|
||
"user_id": user_id,
|
||
"session_id": session_id,
|
||
"response": reply_text,
|
||
"action_payload": action_payload
|
||
}
|
||
```
|
||
|
||
## File: `./modules/web_api/routers/remote_workers.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/routers/remote_workers.py
|
||
ROLE: REST API реестра удаленщиков (CRUD, редактирование сроков, автоочистка).
|
||
===============================================================================
|
||
"""
|
||
|
||
import os
|
||
from datetime import datetime
|
||
import pandas as pd
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from pydantic import BaseModel
|
||
from typing import Optional, List, Dict, Any
|
||
|
||
from routers.auth import get_current_user
|
||
from config import BASE_DIR, DATA_DIR
|
||
|
||
router = APIRouter(prefix="/api/v1/remote-workers", tags=["RemoteWorkers"])
|
||
CSV_PATH = os.path.join(DATA_DIR, "static_reason_workers.csv")
|
||
|
||
|
||
def _load_workers() -> List[Dict[str, Any]]:
|
||
"""Читает CSV, гарантирует структуру колонок и удаляет просроченные записи."""
|
||
if not os.path.exists(CSV_PATH):
|
||
return []
|
||
try:
|
||
df = pd.read_csv(CSV_PATH, dtype=str, on_bad_lines='skip').fillna("")
|
||
for col in ["fio", "department", "reason", "date_from", "date_to"]:
|
||
if col not in df.columns:
|
||
df[col] = ""
|
||
|
||
today_date = datetime.now().date()
|
||
valid_workers = []
|
||
has_expired = False
|
||
|
||
for row in df.to_dict(orient="records"):
|
||
fio = str(row.get("fio", "")).strip()
|
||
if not fio:
|
||
continue
|
||
|
||
d_to_str = str(row.get("date_to", "")).strip()
|
||
if d_to_str and d_to_str.lower() not in ["nan", "none", ""]:
|
||
try:
|
||
d_to = datetime.strptime(d_to_str.replace('_', '.'), "%d.%m.%Y").date()
|
||
# Если срок завершился вчера или ранее — запись удаляется из файла
|
||
if today_date > d_to:
|
||
has_expired = True
|
||
continue
|
||
except ValueError:
|
||
pass
|
||
|
||
valid_workers.append(row)
|
||
|
||
# Синхронная перезапись файла при обнаружении истекших сроков
|
||
if has_expired:
|
||
_save_workers(valid_workers)
|
||
|
||
return valid_workers
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
def _save_workers(workers: List[Dict[str, Any]]):
|
||
"""Сохраняет актуальный список в CSV с сохранением колонок."""
|
||
df = pd.DataFrame(workers)
|
||
for col in ["fio", "department", "reason", "date_from", "date_to"]:
|
||
if col not in df.columns:
|
||
df[col] = ""
|
||
os.makedirs(os.path.dirname(CSV_PATH), exist_ok=True)
|
||
df.to_csv(CSV_PATH, index=False, encoding="utf-8")
|
||
|
||
|
||
class RemoteWorkerItem(BaseModel):
|
||
fio: str
|
||
department: Optional[str] = "Все"
|
||
reason: Optional[str] = "Удаленная работа"
|
||
date_from: Optional[str] = ""
|
||
date_to: Optional[str] = ""
|
||
|
||
|
||
class UpdateDatesRequest(BaseModel):
|
||
fio: str
|
||
date_from: Optional[str] = ""
|
||
date_to: Optional[str] = ""
|
||
|
||
|
||
@router.get("")
|
||
def api_get_remote_workers(current_user = Depends(get_current_user)):
|
||
return {"workers": _load_workers()}
|
||
|
||
|
||
@router.post("")
|
||
def api_add_remote_worker(item: RemoteWorkerItem, current_user = Depends(get_current_user)):
|
||
workers = _load_workers()
|
||
fio_clean = item.fio.strip()
|
||
if not fio_clean:
|
||
raise HTTPException(status_code=400, detail="ФИО не может быть пустым")
|
||
|
||
# Если начало не указано — берем сегодня
|
||
today_str = datetime.now().strftime("%d.%m.%Y")
|
||
date_from = item.date_from.strip() if item.date_from and item.date_from.strip() else today_str
|
||
date_to = item.date_to.strip() if item.date_to else ""
|
||
|
||
# Проверка на совпадение ФИО (обновление существующей записи)
|
||
for w in workers:
|
||
if str(w.get("fio", "")).strip().lower() == fio_clean.lower():
|
||
w["department"] = item.department.strip() if item.department else (w.get("department") or "Все")
|
||
w["reason"] = item.reason.strip() if item.reason else (w.get("reason") or "Удаленная работа")
|
||
w["date_from"] = date_from
|
||
w["date_to"] = date_to
|
||
_save_workers(workers)
|
||
return {"status": "success", "message": "Срок удаленки обновлен", "workers": workers}
|
||
|
||
workers.append({
|
||
"fio": fio_clean,
|
||
"department": item.department.strip() if item.department else "Все",
|
||
"reason": item.reason.strip() if item.reason else "Удаленная работа",
|
||
"date_from": date_from,
|
||
"date_to": date_to
|
||
})
|
||
_save_workers(workers)
|
||
return {"status": "success", "workers": workers}
|
||
|
||
|
||
@router.put("")
|
||
def api_update_worker_dates(req: UpdateDatesRequest, current_user = Depends(get_current_user)):
|
||
"""Редактирование срока удаленки (продление или сокращение)."""
|
||
workers = _load_workers()
|
||
target_fio = req.fio.strip().lower()
|
||
found = False
|
||
|
||
for w in workers:
|
||
if str(w.get("fio", "")).strip().lower() == target_fio:
|
||
w["date_from"] = req.date_from.strip() if req.date_from is not None else w.get("date_from", "")
|
||
w["date_to"] = req.date_to.strip() if req.date_to is not None else w.get("date_to", "")
|
||
found = True
|
||
break
|
||
|
||
if not found:
|
||
raise HTTPException(status_code=404, detail="Сотрудник не найден в списке")
|
||
|
||
_save_workers(workers)
|
||
return {"status": "success", "message": "Сроки успешно изменены", "workers": workers}
|
||
|
||
|
||
@router.delete("")
|
||
def api_delete_remote_worker(fio: str, current_user = Depends(get_current_user)):
|
||
workers = _load_workers()
|
||
fio_clean = fio.strip().lower()
|
||
initial_len = len(workers)
|
||
workers = [w for w in workers if str(w.get("fio", "")).strip().lower() != fio_clean]
|
||
|
||
if len(workers) == initial_len:
|
||
raise HTTPException(status_code=404, detail="Сотрудник не найден")
|
||
|
||
_save_workers(workers)
|
||
return {"status": "success", "workers": workers}
|
||
```
|
||
|
||
## File: `./modules/web_api/routers/manual_absences.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/routers/manual_absences.py
|
||
ROLE: REST API эндпоинты для реестров "Мест. командир.", "Иное" и автокомплита.
|
||
===============================================================================
|
||
"""
|
||
|
||
from fastapi import APIRouter, HTTPException, Query
|
||
from pydantic import BaseModel
|
||
from typing import Optional, List, Dict, Any
|
||
|
||
from services.manual_absences_repo import (
|
||
search_staff_suggestions,
|
||
get_static_reasons,
|
||
add_manual_absence,
|
||
delete_manual_absence,
|
||
get_manual_absences_list
|
||
)
|
||
|
||
router = APIRouter(prefix="/api/v1/manual-absences", tags=["Manual Absences"])
|
||
|
||
|
||
class AddAbsenceRequest(BaseModel):
|
||
absence_type: str # 'LOCAL_TRIP' или 'OTHER'
|
||
fio: str
|
||
reason: Optional[str] = ""
|
||
department: Optional[str] = ""
|
||
position: Optional[str] = ""
|
||
date_start: Optional[str] = None
|
||
date_end: Optional[str] = None
|
||
comment: Optional[str] = ""
|
||
|
||
|
||
@router.get("/staff-autocomplete")
|
||
def api_staff_autocomplete(q: str = Query(..., min_length=2)):
|
||
return search_staff_suggestions(q)
|
||
|
||
|
||
@router.get("/reasons")
|
||
def api_get_reasons():
|
||
return {"reasons": get_static_reasons()}
|
||
|
||
|
||
@router.get("/")
|
||
def api_list_manual_absences(type: Optional[str] = None):
|
||
return {"items": get_manual_absences_list(type)}
|
||
|
||
|
||
@router.post("/")
|
||
def api_add_manual_absence(req: AddAbsenceRequest):
|
||
reason = req.reason or ("Местная командировка" if req.absence_type == "LOCAL_TRIP" else "Иное")
|
||
res_id = add_manual_absence(
|
||
absence_type=req.absence_type,
|
||
fio=req.fio,
|
||
reason=reason,
|
||
department=req.department,
|
||
position=req.position,
|
||
date_start=req.date_start,
|
||
date_end=req.date_end,
|
||
comment=req.comment
|
||
)
|
||
if not res_id:
|
||
raise HTTPException(status_code=400, detail="Не удалось добавить запись")
|
||
return {"status": "success", "id": res_id}
|
||
|
||
|
||
@router.delete("/{item_id}")
|
||
def api_delete_manual_absence(item_id: int):
|
||
if not delete_manual_absence(item_id):
|
||
raise HTTPException(status_code=404, detail="Запись не найдена")
|
||
return {"status": "success"}
|
||
```
|
||
|
||
## File: `./modules/web_api/routers/exceptions.py`
|
||
```py
|
||
from fastapi import APIRouter, HTTPException
|
||
from pydantic import BaseModel
|
||
from typing import Optional, Dict, List
|
||
from services.exceptions_repo import get_all_exceptions_from_db, add_exception_to_db, remove_exception_from_db
|
||
|
||
router = APIRouter(prefix="/api/v1/exceptions", tags=["Exceptions"])
|
||
|
||
|
||
class ExceptionItem(BaseModel):
|
||
category: str
|
||
value: str
|
||
comment: Optional[str] = ""
|
||
|
||
|
||
@router.get("/")
|
||
def api_get_exceptions():
|
||
return get_all_exceptions_from_db()
|
||
|
||
|
||
@router.post("/")
|
||
def api_add_exception(item: ExceptionItem):
|
||
if not add_exception_to_db(item.category, item.value, item.comment):
|
||
raise HTTPException(status_code=400, detail="Ошибка добавления исключения")
|
||
return {"status": "success", "data": item}
|
||
|
||
|
||
@router.delete("/")
|
||
def api_delete_exception(category: str, value: str):
|
||
if not remove_exception_from_db(category, value):
|
||
raise HTTPException(status_code=404, detail="Исключение не найдено")
|
||
return {"status": "success"}
|
||
```
|
||
|
||
## File: `./modules/web_api/routers/snapshots.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/routers/snapshots.py
|
||
ROLE: REST API эндпоинты для управления и моментального создания срезов СКУД.
|
||
===============================================================================
|
||
"""
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from pydantic import BaseModel
|
||
from typing import Optional, List, Dict, Any
|
||
|
||
from routers.auth import get_current_user
|
||
from services.snapshots.service import get_snapshots_registry, delete_snapshots_safely
|
||
from services.scud_export import run_export
|
||
|
||
router = APIRouter(prefix="/api/v1/snapshots", tags=["Snapshots"])
|
||
|
||
|
||
class CreateSnapshotRequest(BaseModel):
|
||
date_str: Optional[str] = None
|
||
|
||
|
||
class DeleteSnapshotsRequest(BaseModel):
|
||
snapshot_ids: List[str]
|
||
|
||
|
||
@router.get("")
|
||
def api_get_snapshots(date_str: Optional[str] = None, current_user = Depends(get_current_user)):
|
||
return get_snapshots_registry(date_str=date_str)
|
||
|
||
|
||
@router.post("/create")
|
||
def api_create_instant_snapshot(req: CreateSnapshotRequest, current_user = Depends(get_current_user)):
|
||
"""Моментальный опрос MS SQL СКУД и запись свежего среза в SQLite."""
|
||
try:
|
||
success = run_export(input_date=req.date_str, save_xlsx=True, debug=False)
|
||
if not success:
|
||
raise HTTPException(status_code=500, detail="Ошибка при обращении к MS SQL Орион")
|
||
|
||
fresh_data = get_snapshots_registry(date_str=req.date_str)
|
||
return {"status": "success", "message": "Срез успешно создан", "data": fresh_data}
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=f"Ошибка создания среза: {str(e)}")
|
||
|
||
|
||
@router.delete("")
|
||
def api_delete_snapshots(req: DeleteSnapshotsRequest, current_user = Depends(get_current_user)):
|
||
safe_ids = [s for s in req.snapshot_ids if not str(s).startswith("Y")]
|
||
if not safe_ids:
|
||
raise HTTPException(status_code=400, detail="Итоговый Y-срез защищен от удаления")
|
||
|
||
res = delete_snapshots_safely(snapshot_ids=safe_ids)
|
||
return {"status": "success", "deleted_count": res.get("deleted_count", len(safe_ids))}
|
||
```
|
||
|
||
## File: `./modules/web_api/routers/tasks.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/routers/tasks.py
|
||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||
MODULE: web_api / routers
|
||
ROLE: REST API эндпоинты реестра задач.
|
||
===============================================================================
|
||
"""
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from pydantic import BaseModel
|
||
from typing import Optional, Dict, Any
|
||
|
||
from routers.auth import get_current_user
|
||
from services.tasks.service import get_tasks, add_task, update_task_details
|
||
|
||
router = APIRouter(prefix="/api/v1/tasks", tags=["Tasks"])
|
||
|
||
|
||
class TaskCreateRequest(BaseModel):
|
||
title: str
|
||
priority: Optional[str] = "MEDIUM"
|
||
module: Optional[str] = "general"
|
||
due_date: Optional[str] = None
|
||
status: Optional[str] = "BACKLOG"
|
||
|
||
|
||
class TaskUpdateRequest(BaseModel):
|
||
title: Optional[str] = None
|
||
priority: Optional[str] = None
|
||
due_date: Optional[str] = None
|
||
status: Optional[str] = None
|
||
|
||
|
||
def resolve_user_id(current_user: Dict[str, Any]) -> int:
|
||
if not current_user:
|
||
return 1
|
||
return current_user.get("id") or current_user.get("user_id") or 1
|
||
|
||
|
||
@router.get("")
|
||
async def get_tasks_endpoint(status: Optional[str] = None, current_user = Depends(get_current_user)):
|
||
user_id = resolve_user_id(current_user)
|
||
return {"tasks": get_tasks(user_id=user_id, status=status)}
|
||
|
||
|
||
@router.post("")
|
||
async def create_task_endpoint(req: TaskCreateRequest, current_user = Depends(get_current_user)):
|
||
user_id = resolve_user_id(current_user)
|
||
res = add_task(
|
||
user_id=user_id,
|
||
module=req.module,
|
||
title=req.title,
|
||
priority=req.priority,
|
||
due_date=req.due_date,
|
||
status=req.status
|
||
)
|
||
if "error" in res:
|
||
raise HTTPException(status_code=400, detail=res["error"])
|
||
return res
|
||
|
||
|
||
@router.patch("/{task_id}")
|
||
async def update_task_endpoint(task_id: str, req: TaskUpdateRequest, current_user = Depends(get_current_user)):
|
||
user_id = resolve_user_id(current_user)
|
||
res = update_task_details(
|
||
user_id=user_id,
|
||
task_id=task_id,
|
||
title=req.title,
|
||
priority=req.priority,
|
||
status=req.status,
|
||
due_date=req.due_date
|
||
)
|
||
if "error" in res:
|
||
raise HTTPException(status_code=404, detail=res["error"])
|
||
return res
|
||
```
|
||
|
||
## File: `./modules/web_api/routers/auth.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/routers/auth.py
|
||
ROLE: Аутентификация, валидация JWT-токенов и управление паролями.
|
||
===============================================================================
|
||
"""
|
||
|
||
import logging
|
||
from datetime import datetime, timedelta
|
||
from typing import Dict, Any, Optional
|
||
|
||
import jwt
|
||
from passlib.context import CryptContext
|
||
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||
from pydantic import BaseModel
|
||
|
||
from core.connection import get_connection
|
||
|
||
JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure"
|
||
ALGORITHM = "HS256"
|
||
|
||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||
security = HTTPBearer()
|
||
|
||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||
|
||
|
||
def get_db():
|
||
return get_connection(row_factory=True)
|
||
|
||
|
||
def create_access_token(user_id: int, username: str, is_admin: bool) -> str:
|
||
payload = {
|
||
"sub": str(user_id),
|
||
"username": username,
|
||
"is_admin": is_admin,
|
||
"exp": datetime.utcnow() + timedelta(days=30)
|
||
}
|
||
return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM)
|
||
|
||
|
||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]:
|
||
try:
|
||
token = credentials.credentials
|
||
payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM])
|
||
user_id = int(payload.get("sub"))
|
||
username = payload.get("username")
|
||
is_admin = bool(payload.get("is_admin", False))
|
||
return {"id": user_id, "username": username, "is_admin": is_admin}
|
||
except Exception as e:
|
||
logging.warning(f"Auth error: {e}")
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="Недействительный или просроченный токен авторизации",
|
||
headers={"WWW-Authenticate": "Bearer"},
|
||
)
|
||
|
||
|
||
class AuthRequest(BaseModel):
|
||
username: str
|
||
password: str
|
||
|
||
|
||
class ChangePasswordRequest(BaseModel):
|
||
old_password: str
|
||
new_password: str
|
||
|
||
|
||
@router.post("/login")
|
||
def login(req: AuthRequest):
|
||
username = req.username.strip().lower()
|
||
conn = get_db()
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT id, username, password_hash, full_name, is_admin FROM users WHERE username = ?", (username,))
|
||
user = cursor.fetchone()
|
||
conn.close()
|
||
|
||
if not user or not pwd_context.verify(req.password, user["password_hash"]):
|
||
raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль")
|
||
|
||
is_admin = bool(user["is_admin"]) or (user["username"] == "puh")
|
||
token = create_access_token(user["id"], user["username"], is_admin)
|
||
|
||
# Возвращаем full_name (если не задано — отдаем username)
|
||
full_name = user["full_name"] if user["full_name"] else user["username"]
|
||
|
||
return {
|
||
"status": "success",
|
||
"token": token,
|
||
"username": user["username"],
|
||
"full_name": full_name,
|
||
"user_id": user["id"],
|
||
"is_admin": is_admin
|
||
}
|
||
|
||
|
||
@router.post("/change-password")
|
||
def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
|
||
if not req.new_password or len(req.new_password) < 4:
|
||
raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа")
|
||
|
||
conn = get_db()
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],))
|
||
user = cursor.fetchone()
|
||
|
||
if not user or not pwd_context.verify(req.old_password, user["password_hash"]):
|
||
conn.close()
|
||
raise HTTPException(status_code=400, detail="Неверный старый пароль")
|
||
|
||
new_hash = pwd_context.hash(req.new_password)
|
||
cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"]))
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
return {"status": "success", "message": "Пароль успешно изменен"}
|
||
```
|
||
|
||
## File: `./modules/web_api/routers/admin.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/routers/admin.py
|
||
ROLE: Администрирование пользователей и прав доступа.
|
||
===============================================================================
|
||
"""
|
||
|
||
# ANCHOR[ADMIN_ROUTER_IMPORTS]
|
||
import logging
|
||
from typing import Dict, Any, Optional
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from pydantic import BaseModel
|
||
|
||
from .auth import get_current_user, get_db, pwd_context
|
||
|
||
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
|
||
|
||
# ANCHOR[ADMIN_SCHEMAS]
|
||
class CreateUserRequest(BaseModel):
|
||
username: str
|
||
password: str
|
||
full_name: Optional[str] = None
|
||
is_admin: Optional[bool] = False
|
||
|
||
# ANCHOR[ADMIN_ENDPOINTS]
|
||
@router.get("/users")
|
||
def list_users(current_user: Dict[str, Any] = Depends(get_current_user)):
|
||
if not current_user["is_admin"]:
|
||
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
|
||
|
||
conn = get_db()
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC")
|
||
users = [dict(r) for r in cursor.fetchall()]
|
||
conn.close()
|
||
return users
|
||
|
||
@router.post("/users")
|
||
def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
|
||
if not current_user["is_admin"]:
|
||
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
|
||
|
||
username = req.username.strip().lower()
|
||
if not username or not req.password:
|
||
raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль")
|
||
|
||
conn = get_db()
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT id FROM users WHERE username = ?", (username,))
|
||
if cursor.fetchone():
|
||
conn.close()
|
||
raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует")
|
||
|
||
pwd_hash = pwd_context.hash(req.password)
|
||
full_name = req.full_name.strip() if req.full_name else None
|
||
is_admin = 1 if req.is_admin else 0
|
||
|
||
cursor.execute(
|
||
"INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)",
|
||
(username, pwd_hash, full_name, is_admin)
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}")
|
||
return {"status": "success", "message": f"Пользователь {username} создан"}
|
||
|
||
@router.delete("/users/{user_id}")
|
||
def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)):
|
||
if not current_user["is_admin"]:
|
||
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
|
||
|
||
if user_id == current_user["id"]:
|
||
raise HTTPException(status_code=400, detail="Нельзя удалить самого себя")
|
||
|
||
conn = get_db()
|
||
cursor = conn.cursor()
|
||
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
logging.info(f"Удален пользователь ID: {user_id}")
|
||
return {"status": "success", "message": "Пользователь удален"}
|
||
```
|
||
|
||
## File: `./modules/web_api/routers/files.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/routers/files.py
|
||
ROLE: Раздача сформированных отчетов и выгрузок с сохранением оригинальных имен
|
||
через изолированные UUID-директории инструментов.
|
||
===============================================================================
|
||
"""
|
||
|
||
import os
|
||
import time
|
||
import shutil
|
||
import urllib.parse
|
||
from fastapi import APIRouter, HTTPException
|
||
from fastapi.responses import FileResponse
|
||
|
||
router = APIRouter(prefix="/api/v1/files", tags=["Files"])
|
||
|
||
BASE_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../"))
|
||
WEB_OUTPUT_DIR = os.path.join(BASE_ROOT, "output", "web")
|
||
os.makedirs(WEB_OUTPUT_DIR, exist_ok=True)
|
||
|
||
SESSION_TTL_HOURS = 24 # Срок жизни временных сессионных выгрузок
|
||
|
||
|
||
def purge_old_tool_sessions(tool_dir_path: str):
|
||
"""Удаляет временные UUID-папки старше SESSION_TTL_HOURS внутри инструмента."""
|
||
if not os.path.exists(tool_dir_path):
|
||
return
|
||
now = time.time()
|
||
cutoff = now - (SESSION_TTL_HOURS * 3600)
|
||
try:
|
||
for entry in os.listdir(tool_dir_path):
|
||
subpath = os.path.join(tool_dir_path, entry)
|
||
if os.path.isdir(subpath):
|
||
if os.path.getmtime(subpath) < cutoff:
|
||
shutil.rmtree(subpath, ignore_errors=True)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
@router.get("/download/{tool_name}/{session_uuid}/{filename}")
|
||
async def download_file(tool_name: str, session_uuid: str, filename: str):
|
||
"""
|
||
Безопасная отдача файла с каноническим именем из изолированной директории.
|
||
"""
|
||
safe_tool = os.path.basename(tool_name)
|
||
safe_uuid = os.path.basename(session_uuid)
|
||
safe_filename = os.path.basename(filename)
|
||
|
||
file_path = os.path.join(WEB_OUTPUT_DIR, safe_tool, safe_uuid, safe_filename)
|
||
|
||
if not os.path.exists(file_path) or not os.path.isfile(file_path):
|
||
raise HTTPException(status_code=404, detail="Файл не найден или срок его действия истек")
|
||
|
||
# Определение MIME-типа
|
||
media_type = "application/octet-stream"
|
||
if safe_filename.endswith(".md") or safe_filename.endswith(".txt"):
|
||
media_type = "text/markdown; charset=utf-8"
|
||
elif safe_filename.endswith(".xlsx"):
|
||
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||
elif safe_filename.endswith(".pdf"):
|
||
media_type = "application/pdf"
|
||
|
||
# Корректная кодировка для кириллических имен файлов
|
||
encoded_filename = urllib.parse.quote(safe_filename)
|
||
|
||
return FileResponse(
|
||
path=file_path,
|
||
media_type=media_type,
|
||
headers={
|
||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
|
||
}
|
||
)
|
||
```
|
||
|
||
## File: `./modules/web_api/routers/context.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/routers/context.py
|
||
ROLE: REST API мониторинга состояния сессии и очистки памяти диалога.
|
||
===============================================================================
|
||
"""
|
||
|
||
from fastapi import APIRouter, Depends
|
||
from pydantic import BaseModel
|
||
from typing import Optional
|
||
|
||
from routers.auth import get_current_user
|
||
from modules.web_api.llm.db.db_prompts import db_get_session_state, db_clear_session_state
|
||
from modules.web_api.llm.db.db_chat import db_get_chat_history, db_purge_ephemeral_messages, db_clear_chat_history
|
||
|
||
router = APIRouter(prefix="/api/v1/context", tags=["Context"])
|
||
|
||
|
||
class SessionActionRequest(BaseModel):
|
||
session_id: Optional[str] = "web_session_main"
|
||
|
||
|
||
@router.get("/state")
|
||
def api_get_context_state(session_id: str = "web_session_main", current_user = Depends(get_current_user)):
|
||
state = db_get_session_state(session_id) or {}
|
||
history = db_get_chat_history(session_id, limit=50)
|
||
|
||
ephemeral_count = sum(1 for m in history if dict(m).get("is_ephemeral") == 1)
|
||
total_messages = len(history)
|
||
|
||
return {
|
||
"session_id": session_id,
|
||
"active_state": state.get("state_type", "IDLE"),
|
||
"state_data": state.get("data_json", {}),
|
||
"total_messages": total_messages,
|
||
"ephemeral_messages": ephemeral_count
|
||
}
|
||
|
||
|
||
@router.post("/purge-ephemeral")
|
||
def api_purge_ephemeral(req: SessionActionRequest, current_user = Depends(get_current_user)):
|
||
purged = db_purge_ephemeral_messages(req.session_id)
|
||
return {"status": "success", "purged_count": purged}
|
||
|
||
|
||
@router.post("/clear-all")
|
||
def api_clear_all_context(req: SessionActionRequest, current_user = Depends(get_current_user)):
|
||
db_clear_session_state(req.session_id)
|
||
db_clear_chat_history(req.session_id)
|
||
return {"status": "success", "message": "Контекст сессии полностью очищен"}
|
||
```
|
||
|
||
## File: `./modules/web_api/llm/agent.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/llm/agent.py
|
||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||
MODULE: web_api / llm (Core Agent Coordinator)
|
||
ROLE: Нативный оркестратор диалога, диспетчер Function Calling,
|
||
передача активных срезов в контекст модели и терминальные вызовы.
|
||
===============================================================================
|
||
"""
|
||
|
||
import sys
|
||
import json
|
||
import logging
|
||
from typing import List, Dict, Any, Tuple, Optional
|
||
|
||
from .db.connection import get_db_connection
|
||
from .db_tools import (
|
||
db_get_active_system_prompt,
|
||
db_apply_prompt_node_action,
|
||
db_get_tool_action,
|
||
db_get_tasks,
|
||
db_update_task_details,
|
||
db_delete_task,
|
||
db_add_task,
|
||
db_tasks_edit,
|
||
db_export_tasks_markdown,
|
||
db_get_rules,
|
||
db_set_session_state,
|
||
db_get_session_state,
|
||
db_clear_session_state,
|
||
db_get_snapshots,
|
||
db_delete_snapshots,
|
||
db_get_current_server_time,
|
||
db_save_chat_message,
|
||
db_get_chat_history,
|
||
db_purge_ephemeral_messages,
|
||
db_get_stats,
|
||
db_get_anomalies,
|
||
db_get_reference
|
||
)
|
||
|
||
from .schemas import TOOLS_SCHEMA
|
||
from .core.calendar_utils import get_dynamic_calendar_context
|
||
from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed
|
||
from .core.ollama_client import call_ollama_chat
|
||
from .core.fast_path import handle_fast_path_intercept
|
||
from .core.context_manager import (
|
||
save_tool_interaction,
|
||
save_dialog_interaction,
|
||
mark_last_user_message_ephemeral,
|
||
close_tool_session_and_cleanup
|
||
)
|
||
|
||
logger = logging.getLogger("SCUD_AGENT")
|
||
logger.setLevel(logging.INFO)
|
||
logger.propagate = False
|
||
|
||
if not logger.handlers:
|
||
handler = logging.StreamHandler(sys.stdout)
|
||
formatter = logging.Formatter("%(asctime)s [%(levelname)s] [%(name)s] %(message)s")
|
||
handler.setFormatter(formatter)
|
||
logger.addHandler(handler)
|
||
|
||
|
||
def process_chat_message(
|
||
user_id: int,
|
||
user_message: str,
|
||
file_context: str = "",
|
||
image_b64: Optional[str] = None,
|
||
chat_history: List[Dict[str, Any]] = None,
|
||
session_id: str = "web_session_main"
|
||
) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||
"""
|
||
Главный конвейер обработки входящего сообщения чата на базе нативного Function Calling.
|
||
"""
|
||
logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
|
||
|
||
session_state = db_get_session_state(session_id)
|
||
if not session_state:
|
||
db_purge_ephemeral_messages(session_id)
|
||
|
||
full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message
|
||
|
||
# 1. Быстрый перехват системных кнопок UI и терминальных действий без задержек LLM
|
||
fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state)
|
||
if fast_path_res:
|
||
return fast_path_res
|
||
|
||
# 2. Фиксация сообщения пользователя
|
||
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
|
||
db_history = db_get_chat_history(session_id, limit=20)
|
||
|
||
calendar_context = get_dynamic_calendar_context()
|
||
user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость"
|
||
|
||
current_state_type = session_state.get("state_type") if session_state else None
|
||
state_data = session_state.get("data_json") or {} if session_state else {}
|
||
if not isinstance(state_data, dict):
|
||
state_data = {}
|
||
idle_turns = state_data.get("idle_turns", 0)
|
||
|
||
active_state_context = ""
|
||
if current_state_type == "PROMPT_PREVIEW":
|
||
active_state_context = "\n[ТЕКУЩИЙ РЕЖИМ: ПРЕДПРОСМОТР СИСТЕМНОГО ПРОМПТА]\n- Открыт предпросмотр изменений промпта.\n"
|
||
elif current_state_type == "SNAPSHOTS_VIEW":
|
||
active_date = state_data.get("query_date", "выбранную дату")
|
||
active_state_context = f"\n[ТЕКУЩИЙ РЕЖИМ: ПРОСМОТР СНАПШОТОВ СКУД]\n- Отображаются срезы за {active_date}.\n"
|
||
elif current_state_type == "SNAPSHOT_INSPECT":
|
||
# ⭐️ Защита от залипания: если вопрос бытовой или отвлеченный, выходим из жесткого режима инспекции
|
||
msg_l = user_message.lower().strip()
|
||
scud_terms = ["срез", "скуд", "вход", "выход", "здани", "присутств", "отсутств", "кто в", "кто сейчас", "1с", "зуп", "турникет", "карточк", "инспекци"]
|
||
if not any(t in msg_l for t in scud_terms) and len(msg_l.split()) <= 12:
|
||
db_clear_session_state(session_id)
|
||
current_state_type = None
|
||
active_state_context = ""
|
||
else:
|
||
snap_id = state_data.get("snapshot_id", "")
|
||
snap_date = state_data.get("log_date", "")
|
||
records = state_data.get("records", [])
|
||
|
||
lines = []
|
||
for r in records:
|
||
st = "Присутствовал" if r.get("is_present") else "Отсутствовал"
|
||
lines.append(f"- {r.get('fio')}: Отдел={r.get('department')}, Вход={r.get('time_in')}, Активность={r.get('first_activity')}, Выход={r.get('time_out')}, ВремяВЗдании={r.get('in_building')}, Статус={st}")
|
||
|
||
dump_str = "\n".join(lines)
|
||
active_state_context = (
|
||
f"\n[ТЕКУЩИЙ РЕЖИМ: АКТИВНА ИНСПЕКЦИЯ СРЕЗА СКУД #{snap_id} ЗА {snap_date}]\n"
|
||
f"Оператор сейчас изучает срез #{snap_id}. При любых вопросах о сотрудниках, фильтрации по входам, выходам, времени или отделам:\n"
|
||
f"1. ТЫ ОБЯЗАН ответить обычным текстом, проанализировав список ниже.\n"
|
||
f"2. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО вызывать инструменты (tools), такие как db_get_snapshots!\n"
|
||
f"Список сотрудников в активном срезе:\n{dump_str}\n"
|
||
)
|
||
|
||
# ⭐️ Промпт с поддержкой Topic Drift и защитой от переспросов по задачам
|
||
system_prompt_content = (
|
||
f"Ты — интеллектуальный ассистент SCUD Orion AI.\n"
|
||
f"Твоя основная роль — помощь оператору в кадровом аудите СКУД/1С, управлении задачами и настройками системы.\n\n"
|
||
f"СТРОГИЕ ПРАВИЛА:\n"
|
||
f"1. К оператору всегда обращайся на Вы.\n"
|
||
f"2. Для работы с данными системы ВСЕГДА вызывай соответствующие инструменты (tools):\n"
|
||
f" - Снапшоты и срезы СКУД -> db_get_snapshots\n"
|
||
f" - Удаление срезов -> db_delete_snapshots\n"
|
||
f" - Задачи и бэклог -> db_get_tasks, db_tasks_edit\n"
|
||
f" - Системный промпт -> db_get_system_prompt, db_prompt_node_edit\n"
|
||
f" - База знаний и регламенты -> db_get_rules\n"
|
||
f" - Аномалии СКУД/1С -> db_get_anomalies\n"
|
||
f" - Справка -> db_get_reference\n"
|
||
f"3. ЗАДАЧИ:\n"
|
||
f" - При любых запросах на просмотр задач (включая опечатки вроде 'змдачи', 'таски', 'дела', 'покажи задачи') — "
|
||
f"ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_get_tasks без лишних вопросов!\n"
|
||
f" - КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО переспрашивать у оператора фильтры, статус или категорию задач текстом! "
|
||
f"Интерактивная карточка в интерфейсе содержит все нужные фильтры.\n"
|
||
f"4. ПРАВИЛА И РЕГЛАМЕНТЫ: При любых вопросах о правилах компании или арбитраже ТЫ ОБЯЗАН СРАЗУ вызвать db_get_rules.\n"
|
||
f"5. Запрещено выдумывать факты и цифры по системе СКУД/1С без вызова инструментов.\n"
|
||
f"6. ОБЩИЙ ДИАЛОГ: На любые отвлечённые, познавательные, научные или бытовые вопросы "
|
||
f"(расстояние между планетами или городами, программирование, кругозор) отвечай полно, доброжелательно и интересно, не отказывая пользователю.\n\n"
|
||
f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n"
|
||
f"- Пользователь: {user_info}\n"
|
||
f"- {calendar_context}\n"
|
||
f"{active_state_context}"
|
||
)
|
||
|
||
user_msg_object = {"role": "user", "content": full_user_content}
|
||
|
||
try:
|
||
if image_b64:
|
||
user_msg_object["images"] = [image_b64]
|
||
messages = [
|
||
{"role": "system", "content": "Ты — строгий модуль OCR. Перепиши весь текст с изображения буква в букву."},
|
||
user_msg_object
|
||
]
|
||
msg = call_ollama_chat(messages, is_vision=True)
|
||
else:
|
||
clean_db_history = [dict(m) for m in db_history]
|
||
for m in clean_db_history:
|
||
m.pop("images", None)
|
||
messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object]
|
||
msg = call_ollama_chat(messages, tools=TOOLS_SCHEMA, is_vision=False)
|
||
|
||
raw_text_reply = msg.get("content", "")
|
||
tool_calls = msg.get("tool_calls", [])
|
||
|
||
if not tool_calls:
|
||
tool_calls = inject_tools_if_needed(user_message, raw_text_reply, tool_calls)
|
||
|
||
if tool_calls:
|
||
logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})")
|
||
|
||
tool = tool_calls[0]
|
||
fn_name = tool["function"]["name"]
|
||
fn_args = tool["function"].get("arguments", {})
|
||
if isinstance(fn_args, str):
|
||
try:
|
||
fn_args = json.loads(fn_args)
|
||
except Exception:
|
||
fn_args = {}
|
||
|
||
logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}")
|
||
|
||
# Ротация контекста
|
||
close_tool_session_and_cleanup(session_id, close_reason=f"ACTIVATE_{fn_name}")
|
||
session_state = None
|
||
mark_last_user_message_ephemeral(session_id)
|
||
|
||
# 1. Задачи (Просмотр)
|
||
if fn_name == "db_get_tasks":
|
||
raw_tasks = db_get_tasks(user_id, status=fn_args.get("status"))
|
||
reply_text = "Вот интерактивный список ваших текущих задач:"
|
||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||
return reply_text, db_get_chat_history(session_id), {
|
||
"type": "TASK_INTERACTIVE_CARD",
|
||
"tasks": raw_tasks
|
||
}
|
||
|
||
# 2. Единый диспетчер задач
|
||
elif fn_name in ["db_tasks_edit", "db_add_task", "db_update_task_details", "db_delete_task"]:
|
||
action = fn_args.get("action", "UPDATE").upper()
|
||
if fn_name == "db_add_task": action = "ADD"
|
||
elif fn_name == "db_delete_task": action = "DELETE"
|
||
elif fn_name == "db_update_task_status": action = "UPDATE"
|
||
|
||
if action == "DELETE":
|
||
task_id_raw = str(fn_args.get("task_id", "")).replace("#", "").replace("TASK-", "").strip()
|
||
db_set_session_state(session_id, "TASK_DELETE_CONFIRM", {"task_id": task_id_raw, "idle_turns": 0})
|
||
reply_text = f"Вы действительно хотите удалить задачу #{task_id_raw}?"
|
||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||
return reply_text, db_get_chat_history(session_id), {
|
||
"type": "TASK_DELETE_CONFIRM",
|
||
"buttons": [
|
||
{"label": f"Удалить #{task_id_raw}", "value": f"подтверждаю удаление задачи {task_id_raw}", "style": "danger"},
|
||
{"label": "Отмена", "value": "отмена", "style": "secondary"}
|
||
]
|
||
}
|
||
|
||
elif action == "EXPORT":
|
||
export_res = db_export_tasks_markdown(
|
||
user_id=user_id,
|
||
filename=fn_args.get("filename") or "ROADMAP.md",
|
||
status_filter=fn_args.get("status")
|
||
)
|
||
reply_text = export_res.get("message", "Отчет по задачам успешно экспортирован.")
|
||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
|
||
|
||
action_payload = None
|
||
if export_res.get("status") == "success":
|
||
action_payload = {
|
||
"type": "FILE_DOWNLOAD_CARD",
|
||
"filename": export_res.get("filename"),
|
||
"download_url": export_res.get("download_url"),
|
||
"tasks_count": export_res.get("tasks_count")
|
||
}
|
||
return reply_text, db_get_chat_history(session_id), action_payload
|
||
|
||
else:
|
||
res = db_tasks_edit(
|
||
user_id=user_id,
|
||
action=action,
|
||
task_id=fn_args.get("task_id"),
|
||
title=fn_args.get("title"),
|
||
priority=fn_args.get("priority", "MEDIUM"),
|
||
status=fn_args.get("status"),
|
||
module=fn_args.get("module", "general"),
|
||
due_date=fn_args.get("due_date")
|
||
)
|
||
raw_tasks = db_get_tasks(user_id)
|
||
reply_text = res.get("message", "Операция над задачами выполнена.")
|
||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||
return reply_text, db_get_chat_history(session_id), {
|
||
"type": "TASK_INTERACTIVE_CARD",
|
||
"tasks": raw_tasks
|
||
}
|
||
|
||
# 3. Системный промпт
|
||
elif fn_name == "db_get_system_prompt":
|
||
active_prompt = db_get_active_system_prompt()
|
||
reply_text = (
|
||
"📋 **АКТУАЛЬНЫЙ СИСТЕМНЫЙ ПРОМПТ ИЗ БАЗЫ ДАННЫХ:**\n\n"
|
||
f"```text\n{active_prompt}\n```\n\n"
|
||
"Вы можете добавить, отредактировать или удалить любой пункт."
|
||
)
|
||
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0})
|
||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||
return reply_text, db_get_chat_history(session_id), {
|
||
"type": "PROMPT_VIEW",
|
||
"buttons": [
|
||
{"label": "✏️ Редактировать промпт", "value": "action:open_editor", "style": "primary"},
|
||
{"label": "База знаний", "value": "покажи правила компании", "style": "secondary"}
|
||
]
|
||
}
|
||
|
||
# 4. База знаний и правила компании (терминальный возврат)
|
||
elif fn_name == "db_get_rules":
|
||
rules_data = db_get_rules()
|
||
if isinstance(rules_data, dict) and "rules" in rules_data:
|
||
rules_list = rules_data["rules"]
|
||
elif isinstance(rules_data, list):
|
||
rules_list = rules_data
|
||
else:
|
||
rules_list = [str(rules_data)]
|
||
|
||
formatted_rules = "\n\n".join([f"{i+1}. {r.get('rule_text', r) if isinstance(r, dict) else r}" for i, r in enumerate(rules_list)])
|
||
reply_text = (
|
||
"📖 **БАЗА ЗНАНИЙ И ПРАВИЛА КОМПАНИИ (КАДРОВЫЙ АРБИТРАЖ):**\n\n"
|
||
f"```text\n{formatted_rules}\n```"
|
||
)
|
||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||
return reply_text, db_get_chat_history(session_id), {
|
||
"type": "RULES_VIEW",
|
||
"buttons": [
|
||
{"label": "✏️ Редактировать правила", "value": "action:open_rules_editor", "style": "primary"},
|
||
{"label": "📋 Системный промпт", "value": "покажи системный промпт", "style": "secondary"}
|
||
]
|
||
}
|
||
|
||
# 5. Снапшоты СКУД
|
||
elif fn_name == "db_get_snapshots":
|
||
snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message)
|
||
query_date = snapshots_res.get("query_date", "выбранную дату")
|
||
reply_text = f"Реестр срезов СКУД за {query_date}:"
|
||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||
return reply_text, db_get_chat_history(session_id), {
|
||
"type": "SNAPSHOTS_CARD",
|
||
"data": snapshots_res
|
||
}
|
||
|
||
elif fn_name == "db_delete_snapshots":
|
||
raw_id = fn_args.get("snapshot_id") or fn_args.get("day_str")
|
||
raw_ids = fn_args.get("snapshot_ids") or []
|
||
is_confirmed = fn_args.get("confirmed", False)
|
||
|
||
if raw_id and not raw_ids:
|
||
if isinstance(raw_id, str) and "," in raw_id:
|
||
raw_ids = [s.strip() for s in raw_id.split(",")]
|
||
else:
|
||
raw_ids = [raw_id]
|
||
|
||
safe_ids = [s.strip() for s in raw_ids if s and not str(s).strip().startswith("Y")]
|
||
if not safe_ids:
|
||
reply_text = "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты."
|
||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||
return reply_text, db_get_chat_history(session_id), None
|
||
|
||
if not is_confirmed:
|
||
query_date = state_data.get("query_date", "")
|
||
db_set_session_state(session_id, "SNAPSHOT_DELETE_CONFIRM", {
|
||
"snapshot_ids": safe_ids,
|
||
"query_date": query_date,
|
||
"idle_turns": 0
|
||
})
|
||
ids_str = ", ".join(safe_ids)
|
||
reply_text = f"Вы действительно хотите удалить дневные снапшоты: {ids_str}?"
|
||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||
return reply_text, db_get_chat_history(session_id), {
|
||
"type": "SNAPSHOT_DELETE_CONFIRM",
|
||
"buttons": [
|
||
{"label": f"Удалить ({len(safe_ids)} шт.)", "value": f"подтверждаю удаление снапшотов {ids_str}", "style": "danger"},
|
||
{"label": "Отмена", "value": "отмена", "style": "secondary"}
|
||
]
|
||
}
|
||
else:
|
||
db_delete_snapshots(snapshot_ids=safe_ids)
|
||
query_date = state_data.get("query_date", "")
|
||
updated_snapshots_res = db_get_snapshots(session_id=session_id, date_str=query_date)
|
||
|
||
reply_text = f"✅ Успешно удалено снапшотов: {len(safe_ids)} шт."
|
||
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
|
||
return reply_text, db_get_chat_history(session_id), {
|
||
"type": "SNAPSHOTS_CARD",
|
||
"data": updated_snapshots_res
|
||
}
|
||
|
||
# 6. Прочие сервисные инструменты
|
||
elif fn_name == "db_get_current_server_time":
|
||
tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False)
|
||
elif fn_name == "db_get_stats":
|
||
tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False)
|
||
elif fn_name == "db_get_anomalies":
|
||
tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False)
|
||
elif fn_name == "db_get_reference":
|
||
tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False)
|
||
else:
|
||
tool_result_content = "{}"
|
||
|
||
messages.append(msg)
|
||
messages.append({"role": "tool", "content": tool_result_content})
|
||
sec_msg = call_ollama_chat(messages, is_vision=False)
|
||
raw_content = sec_msg.get("content", "").strip().replace("**", "").replace("*", "")
|
||
final_content = clean_raw_tool_tags(clean_output(raw_content)) or "Запрос выполнен."
|
||
|
||
db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=0)
|
||
return final_content, db_get_chat_history(session_id), None
|
||
|
||
# Свободный диалог (Topic Drift)
|
||
raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "")
|
||
final_reply = clean_raw_tool_tags(clean_output(raw_str)) or "Запрос обработан."
|
||
|
||
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0)
|
||
return final_reply, db_get_chat_history(session_id), None
|
||
|
||
except Exception as ex:
|
||
logger.exception(f"Непредвиденная ошибка агента: {ex}")
|
||
error_reply = f"Внутренняя ошибка сервера: {ex}"
|
||
return error_reply, db_get_chat_history(session_id), None
|
||
```
|
||
|
||
## File: `./modules/web_api/llm/schemas.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/llm/schemas.py
|
||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||
MODULE: web_api / llm
|
||
ROLE: Декларативная схема нативных инструментов (Function Calling) для Ollama.
|
||
|
||
AI-CONTEXT-ANCHORS:
|
||
- ANCHOR[SCHEMA_PROMPT_EDIT]: Схема управления узлами системного промпта.
|
||
- ANCHOR[SCHEMA_TASKS_EDIT]: Консолидированная схема управления задачами.
|
||
- ANCHOR[SCHEMA_SNAPSHOTS]: Схема доступа к логам и срезам СКУД.
|
||
===============================================================================
|
||
"""
|
||
|
||
# ANCHOR[SCHEMA_PROMPT_EDIT]
|
||
TOOLS_SCHEMA = [
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "db_prompt_node_edit",
|
||
"description": (
|
||
"Управление элементами системного промпта (добавление, изменение, удаление пунктов).\n"
|
||
"Поддерживает как одиночные пункты (section_id, item_id), так и список пунктов для удаления (nodes_list=['1.8', '3.4']).\n"
|
||
"При любом запросе на удаление или добавление пунктов системного промпта ТЫ ОБЯЗАН вызвать этот инструмент."
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"action": {
|
||
"type": "string",
|
||
"enum": ["ADD", "EDIT", "DELETE", "BATCH_DELETE"],
|
||
"description": "Тип действия"
|
||
},
|
||
"section_id": {
|
||
"type": "integer",
|
||
"description": "Номер раздела (например 1)"
|
||
},
|
||
"item_id": {
|
||
"type": "integer",
|
||
"description": "Номер пункта (например 8)"
|
||
},
|
||
"nodes_list": {
|
||
"type": "array",
|
||
"items": {"type": "string"},
|
||
"description": "Список пунктов для удаления/изменения, например ['1.8', '3.4']"
|
||
},
|
||
"content": {
|
||
"type": "string",
|
||
"description": "Текст пункта"
|
||
}
|
||
},
|
||
"required": ["action"]
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "db_get_system_prompt",
|
||
"description": "Просмотр текущего активного системного промпта ассистента из базы данных.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {}
|
||
}
|
||
}
|
||
},
|
||
# ANCHOR[SCHEMA_TASKS_EDIT]
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "db_get_tasks",
|
||
"description": (
|
||
"Просмотр реестра задач и бэклога текущего пользователя.\n"
|
||
"Вызывай этот инструмент ВСЕГДА при любых запросах просмотра задач ('покажи задачи', 'мои задачи', опечатки 'змдачи').\n"
|
||
"Запрещено переспрашивать статус или параметры текстом: просто вызывай функцию с аргументами {}."
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"status": {
|
||
"type": "string",
|
||
"enum": ["ALL", "IN_PROGRESS", "PLANNED", "COMPLETED"],
|
||
"description": "Опциональный фильтр статуса задач (по умолчанию ALL)"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "db_tasks_edit",
|
||
"description": (
|
||
"Единый инструмент управления задачами: создание (ADD), изменение статуса/срока/названия (UPDATE), удаление (DELETE) и экспорт (EXPORT).\n"
|
||
"СТРОГИЕ ПРАВИЛА ВЫЗОВА:\n"
|
||
"- На любые фразы вида 'удали задачу N', 'удалить N', 'убери задачу N' ТЫ ОБЯЗАН СРАЗУ вызвать инструмент с action='DELETE', task_id='N'.\n"
|
||
"- На фразы 'возьми в работу N' вызывай action='UPDATE', task_id='N', status='IN_PROGRESS'.\n"
|
||
"- На фразы 'заверши N', 'готово N' вызывай action='UPDATE', task_id='N', status='COMPLETED'.\n"
|
||
"- КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО писать текстовые вопросы или запрашивать подтверждения словами! ТЫ ОБЯЗАН СРАЗУ вызвать инструмент."
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"action": {
|
||
"type": "string",
|
||
"enum": ["ADD", "UPDATE", "DELETE", "EXPORT"],
|
||
"description": "Тип действия: ADD, UPDATE, DELETE или EXPORT"
|
||
},
|
||
"task_id": {
|
||
"type": "string",
|
||
"description": "Номер задачи (например: '37')"
|
||
},
|
||
"title": {
|
||
"type": "string",
|
||
"description": "Описание или текст задачи"
|
||
},
|
||
"status": {
|
||
"type": "string",
|
||
"enum": ["IN_PROGRESS", "COMPLETED", "BACKLOG"],
|
||
"description": "Новый статус задачи: IN_PROGRESS (В работу), COMPLETED (Завершено), BACKLOG (В планы)"
|
||
},
|
||
"priority": {
|
||
"type": "string",
|
||
"enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"],
|
||
"description": "Приоритет задачи"
|
||
},
|
||
"module": {
|
||
"type": "string",
|
||
"description": "Модуль проекта"
|
||
},
|
||
"due_date": {
|
||
"type": "string",
|
||
"description": "Срок в формате ГГГГ-ММ-ДД"
|
||
},
|
||
"filename": {
|
||
"type": "string",
|
||
"description": "Имя файла для экспорта"
|
||
}
|
||
},
|
||
"required": ["action"]
|
||
}
|
||
}
|
||
},
|
||
# ANCHOR[SCHEMA_SNAPSHOTS]
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "db_get_snapshots",
|
||
"description": (
|
||
"Получение реестра/списка доступных снапшотов (файлов срезов) СКУД.\n"
|
||
"ВЫЗЫВАТЬ ТОЛЬКО при прямом запросе на список срезов ('покажи срезы', 'какие есть снапшоты', 'срезы за дату').\n"
|
||
"КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО вызывать эту функцию, если пользователь спрашивает о людях, сотрудниках, входах или выходах внутри уже открытого среза!"
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"date_str": {
|
||
"type": "string",
|
||
"description": "Дата в формате ДД.ММ.ГГГГ или относительное слово"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "db_delete_snapshots",
|
||
"description": "Удаление снапшотов СКУД по идентификатору или дате.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"snapshot_id": {
|
||
"type": "string",
|
||
"description": "Идентификатор конкретного снапшота"
|
||
},
|
||
"day_str": {
|
||
"type": "string",
|
||
"description": "Дата всех снапшотов за день"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "db_get_anomalies",
|
||
"description": "Просмотр истории аномалий и расхождений между СКУД и 1С.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"date_str": {
|
||
"type": "string",
|
||
"description": "Опциональная дата в формате ДД.ММ.ГГГГ"
|
||
},
|
||
"limit": {
|
||
"type": "integer",
|
||
"description": "Лимит записей"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "db_get_rules",
|
||
"description": "Просмотр базы знаний и правил кадрового арбитража компании.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {}
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "db_get_stats",
|
||
"description": "Получение статистики количества записей в таблицах базы данных.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {}
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "db_get_current_server_time",
|
||
"description": "Получение текущего точного времени сервера.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {}
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "db_get_reference",
|
||
"description": "Справка о возможностях ассистента и примеры доступных команд.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"category": {
|
||
"type": "string",
|
||
"description": "Категория справки"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
]
|
||
```
|
||
|
||
## File: `./modules/web_api/llm/db_tools.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/llm/db_tools.py
|
||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||
MODULE: web_api / llm
|
||
ROLE: Фасадная точка доступа к доменным сервисам (Domain Facade).
|
||
|
||
AI-CONTEXT-ANCHORS:
|
||
- ANCHOR[FACADE_EXPORTS]: Экспорт методов предметных сервисов для LLM и API.
|
||
===============================================================================
|
||
"""
|
||
|
||
# ANCHOR[FACADE_EXPORTS]
|
||
from typing import Optional, List, Dict, Any
|
||
from core.connection import DB_PATH, get_connection as get_db_connection
|
||
|
||
# Домен: Задачи
|
||
from services.tasks.service import (
|
||
get_tasks as db_get_tasks,
|
||
add_task as db_add_task,
|
||
update_task_details as db_update_task_details,
|
||
delete_task as db_delete_task,
|
||
execute_task_action as db_tasks_edit
|
||
)
|
||
# Защитный алиас для обратной совместимости
|
||
db_update_task_status = db_update_task_details
|
||
|
||
from services.tasks.exporter import export_tasks_to_markdown as db_export_tasks_markdown
|
||
from services.tasks.repository import normalize_task_id
|
||
|
||
# Домен: Системный промпт
|
||
from services.prompts.service import (
|
||
get_active_system_prompt as db_get_active_system_prompt,
|
||
apply_prompt_action as db_apply_prompt_node_action,
|
||
save_full_prompt_draft,
|
||
create_prompt_preview
|
||
)
|
||
|
||
# Домен: База знаний
|
||
from services.knowledge.service import (
|
||
get_rules as db_get_rules,
|
||
add_rule as db_add_rule
|
||
)
|
||
|
||
# Чат, сессии, статистика
|
||
from .db.db_chat import (
|
||
db_save_chat_message,
|
||
db_get_chat_history,
|
||
db_purge_ephemeral_messages,
|
||
db_clear_chat_history
|
||
)
|
||
from .db.db_prompts import (
|
||
db_get_tool_action,
|
||
db_set_session_state,
|
||
db_get_session_state,
|
||
db_clear_session_state,
|
||
db_get_stats,
|
||
db_get_anomalies,
|
||
db_get_reference
|
||
)
|
||
from .core.calendar_utils import get_dynamic_calendar_context as db_get_current_server_time
|
||
|
||
|
||
def db_add_system_prompt(name_or_text: str, draft_text: str = None) -> None:
|
||
"""Совместимая обертка для сохранения системного промпта."""
|
||
if draft_text is not None:
|
||
save_full_prompt_draft(draft_text, prompt_name=name_or_text)
|
||
else:
|
||
save_full_prompt_draft(name_or_text, prompt_name="main_agent")
|
||
|
||
|
||
def db_get_snapshots(session_id: str = "web_session_main", date_str: str = None, original_user_message: str = "") -> Dict[str, Any]:
|
||
"""Совместимый фасад выборки снапшотов с сохранением стейта сессии."""
|
||
from services.snapshots.service import get_snapshots_registry
|
||
from .core.calendar_utils import parse_relative_date_ru
|
||
|
||
clean_date = (date_str or "").strip()
|
||
if not clean_date and original_user_message:
|
||
clean_date = parse_relative_date_ru(original_user_message)
|
||
|
||
res = get_snapshots_registry(date_str=clean_date if clean_date else None)
|
||
db_set_session_state(session_id=session_id, state_type="SNAPSHOTS_VIEW", data=res)
|
||
return res
|
||
|
||
|
||
def db_delete_snapshots(snapshot_id: str = None, snapshot_ids: List[str] = None, day_str: str = None) -> Dict[str, Any]:
|
||
"""Совместимый фасад безопасного удаления снапшотов."""
|
||
from services.snapshots.service import delete_snapshots_safely
|
||
|
||
target_ids = []
|
||
if snapshot_ids:
|
||
target_ids.extend(snapshot_ids)
|
||
if snapshot_id:
|
||
if isinstance(snapshot_id, str) and "," in snapshot_id:
|
||
target_ids.extend([s.strip() for s in snapshot_id.split(",")])
|
||
else:
|
||
target_ids.append(snapshot_id)
|
||
|
||
return delete_snapshots_safely(snapshot_ids=target_ids)
|
||
```
|
||
|
||
## File: `./modules/web_api/llm/core/fast_path.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/llm/core/fast_path.py
|
||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||
MODULE: web_api / llm / core
|
||
ROLE: Мгновенный перехват UI-действий, инспекции срезов, экспорта и Diff-превью.
|
||
===============================================================================
|
||
"""
|
||
|
||
import difflib
|
||
import logging
|
||
from typing import Dict, Any, Tuple, Optional
|
||
|
||
from services.prompts.service import save_full_prompt_draft, apply_prompt_action, get_active_system_prompt
|
||
from services.knowledge.service import get_rules, add_rule
|
||
from services.tasks.service import get_tasks, delete_task, execute_task_action
|
||
from services.snapshots.service import get_snapshots_registry, delete_snapshots_safely
|
||
|
||
from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages
|
||
from modules.web_api.llm.db.db_prompts import db_set_session_state, db_clear_session_state
|
||
from modules.web_api.llm.core.context_manager import close_tool_session_and_cleanup
|
||
|
||
logger = logging.getLogger("FAST_PATH")
|
||
|
||
|
||
def _generate_prompt_diff_html(baseline_text: str, draft_text: str) -> str:
|
||
base_lines = [line.rstrip() for line in baseline_text.strip().splitlines()]
|
||
draft_lines = [line.rstrip() for line in draft_text.strip().splitlines()]
|
||
|
||
matcher = difflib.SequenceMatcher(None, base_lines, draft_lines)
|
||
diff_html_lines = []
|
||
|
||
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||
if tag == 'equal':
|
||
for line in base_lines[i1:i2]:
|
||
diff_html_lines.append(line)
|
||
elif tag == 'delete':
|
||
for line in base_lines[i1:i2]:
|
||
diff_html_lines.append(
|
||
f'<span class="line-through text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300 opacity-80">{line} [УДАЛЕНИЕ]</span>'
|
||
)
|
||
elif tag == 'insert':
|
||
for line in draft_lines[j1:j2]:
|
||
diff_html_lines.append(
|
||
f'<span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">{line}</span>'
|
||
)
|
||
elif tag == 'replace':
|
||
for line in base_lines[i1:i2]:
|
||
diff_html_lines.append(
|
||
f'<span class="line-through text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300 opacity-80">{line} [УДАЛЕНИЕ]</span>'
|
||
)
|
||
for line in draft_lines[j1:j2]:
|
||
diff_html_lines.append(
|
||
f'<span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">{line}</span>'
|
||
)
|
||
|
||
return "\n".join(diff_html_lines)
|
||
|
||
|
||
def handle_fast_path_intercept(
|
||
session_id: str,
|
||
user_message: str,
|
||
full_user_content: str,
|
||
session_state: Optional[Dict[str, Any]]
|
||
) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]:
|
||
msg_raw = user_message.strip()
|
||
msg_lower = msg_raw.lower()
|
||
|
||
# -------------------------------------------------------------------------
|
||
# 0.0 ЭКСПОРТ ЗАДАЧ В ФАЙЛ (МГНОВЕННО, БЕЗ ЛИШНИХ ДИАЛОГОВ)
|
||
# -------------------------------------------------------------------------
|
||
if any(msg_lower.startswith(p) for p in ["экспортируй задачи", "экспорт задач", "выгрузи задачи", "скачать задачи"]):
|
||
filename = "ROADMAP.md"
|
||
if " в " in msg_lower:
|
||
parts = msg_raw.split(" в ", 1)[1].strip().split()
|
||
if parts and ("." in parts[0] or parts[0].endswith("md")):
|
||
filename = parts[0]
|
||
|
||
res = execute_task_action(user_id=1, action="EXPORT", filename=filename)
|
||
reply = res.get("message", f"Отчет по задачам сформирован в `{filename}`.")
|
||
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
|
||
|
||
action_payload = {
|
||
"type": "FILE_DOWNLOAD_CARD",
|
||
"filename": res.get("filename", filename),
|
||
"download_url": res.get("download_url", "#"),
|
||
"tasks_count": res.get("tasks_count", 0)
|
||
}
|
||
return reply, db_get_chat_history(session_id), action_payload
|
||
|
||
# -------------------------------------------------------------------------
|
||
# 0.1 ИНСПЕКЦИЯ КОНКРЕТНОГО СРЕЗА СКУД (ПО ID СНАПШОТА)
|
||
# -------------------------------------------------------------------------
|
||
if msg_lower.startswith("покажи срез ") or msg_lower.startswith("инспекция среза "):
|
||
target_snap_id = msg_raw.split()[-1].replace("#", "").strip()
|
||
from core.database import load_scud_from_db_by_snapshot
|
||
|
||
df_snap = load_scud_from_db_by_snapshot(date_str="", snapshot_param=target_snap_id)
|
||
if df_snap is None or df_snap.empty:
|
||
reply = f"⚠️ Срез СКУД `#{target_snap_id}` не найден в базе данных."
|
||
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
|
||
return reply, db_get_chat_history(session_id), None
|
||
|
||
total_cnt = len(df_snap)
|
||
present_cnt = len(df_snap[df_snap['Пришел'] == True]) if 'Пришел' in df_snap.columns else 0
|
||
absent_cnt = total_cnt - present_cnt
|
||
|
||
snap_time = df_snap['snapshot_time'].iloc[0] if 'snapshot_time' in df_snap.columns else '—'
|
||
log_date = df_snap['log_date'].iloc[0] if 'log_date' in df_snap.columns else '—'
|
||
|
||
rows_list = []
|
||
for _, r in df_snap.iterrows():
|
||
rows_list.append({
|
||
"fio": r.get('Сотрудник', r.get('fio', '')),
|
||
"department": r.get('Подразделение', r.get('department_scud', '—')),
|
||
"time_in": r.get('Начало_дня', 'Нет входа'),
|
||
"first_activity": r.get('Первая_активность', '—'),
|
||
"time_out": r.get('Конец_дня', 'Нет выхода'),
|
||
"in_building": r.get('Находился_в_здании', '00:00'),
|
||
"is_present": bool(r.get('Пришел', False)),
|
||
"anomaly": r.get('anomaly_flag', 'NONE')
|
||
})
|
||
|
||
# Фиксация среза в памяти сессии для последующих вопросов к LLM
|
||
db_set_session_state(session_id, "SNAPSHOT_INSPECT", {
|
||
"snapshot_id": target_snap_id,
|
||
"log_date": log_date,
|
||
"snapshot_time": snap_time,
|
||
"records": rows_list,
|
||
"idle_turns": 0
|
||
})
|
||
|
||
reply = f"🔍 **Инспекция среза #{target_snap_id}** (Дата: {log_date}, Время: {snap_time}). Всего записей: {total_cnt} (Присутствовали: {present_cnt}, Отсутствовали: {absent_cnt})."
|
||
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
|
||
|
||
return reply, db_get_chat_history(session_id), {
|
||
"type": "SNAPSHOT_INSPECT_CARD",
|
||
"snapshot_id": target_snap_id,
|
||
"log_date": log_date,
|
||
"snapshot_time": snap_time,
|
||
"total_count": total_cnt,
|
||
"present_count": present_cnt,
|
||
"absent_count": absent_cnt,
|
||
"records": rows_list
|
||
}
|
||
|
||
# -------------------------------------------------------------------------
|
||
# 0.2 ОТКРЫТИЕ ИНЛАЙН-РЕДАКТОРА ПРОМПТА
|
||
# -------------------------------------------------------------------------
|
||
if msg_lower in ["action:open_editor", "редактировать промпт", "открыть редактор"]:
|
||
active_prompt = get_active_system_prompt()
|
||
state_data = session_state.get("data_json") if session_state else {}
|
||
draft_text = state_data.get("draft_text") if isinstance(state_data, dict) and state_data.get("draft_text") else active_prompt
|
||
|
||
db_set_session_state(session_id, "PROMPT_PREVIEW", {
|
||
"draft_text": draft_text,
|
||
"action": "MANUAL_EDIT",
|
||
"idle_turns": 0
|
||
})
|
||
reply = "✏️ Внесите необходимые изменения в текст промпта и нажмите «Показать превью изменений»:"
|
||
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
|
||
return reply, db_get_chat_history(session_id), {
|
||
"type": "PROMPT_EDITOR",
|
||
"raw_draft": draft_text,
|
||
"baseline_prompt": active_prompt
|
||
}
|
||
|
||
# -------------------------------------------------------------------------
|
||
# 0.3 ОТКРЫТИЕ ИНЛАЙН-РЕДАКТОРА ПРАВИЛ
|
||
# -------------------------------------------------------------------------
|
||
if msg_lower in ["action:open_rules_editor", "редактировать правила", "изменить правила"]:
|
||
rules_data = get_rules()
|
||
rules_list = rules_data.get("rules", []) if isinstance(rules_data, dict) else (rules_data if isinstance(rules_data, list) else [str(rules_data)])
|
||
raw_rules_text = "\n".join([f"{i+1}. {r.get('rule_text', r) if isinstance(r, dict) else r}" for i, r in enumerate(rules_list)])
|
||
|
||
db_set_session_state(session_id, "RULES_PREVIEW", {
|
||
"draft_text": raw_rules_text,
|
||
"action": "MANUAL_EDIT_RULES",
|
||
"idle_turns": 0
|
||
})
|
||
reply = "✏️ Редактор базы знаний и правил компании. Внесите изменения и нажмите «Показать превью изменений»:"
|
||
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
|
||
return reply, db_get_chat_history(session_id), {
|
||
"type": "RULES_EDITOR",
|
||
"raw_draft": raw_rules_text,
|
||
"baseline_prompt": raw_rules_text
|
||
}
|
||
|
||
# -------------------------------------------------------------------------
|
||
# 0.4 ПОСТУПЛЕНИЕ ДРАФТА ПРОМПТА -> DIFF-ПРЕВЬЮ
|
||
# -------------------------------------------------------------------------
|
||
if msg_raw.startswith("action:save_draft_prompt:::"):
|
||
new_draft_content = msg_raw.replace("action:save_draft_prompt:::", "").strip()
|
||
active_prompt = get_active_system_prompt()
|
||
diff_html = _generate_prompt_diff_html(active_prompt, new_draft_content)
|
||
|
||
db_set_session_state(session_id, "PROMPT_PREVIEW", {
|
||
"draft_text": new_draft_content,
|
||
"action": "MANUAL_EDIT",
|
||
"idle_turns": 0
|
||
})
|
||
|
||
preview_reply = f"Предпросмотр изменений системного промпта:\n\n{diff_html}\n\nДля применения подтвердите действие, отредактируйте или отмените."
|
||
db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1)
|
||
return preview_reply, db_get_chat_history(session_id), {
|
||
"type": "PROMPT_PREVIEW",
|
||
"raw_draft": new_draft_content,
|
||
"baseline_prompt": active_prompt,
|
||
"diff_html": diff_html,
|
||
"buttons": [
|
||
{"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
|
||
{"label": "Отменить", "value": "отмена", "style": "danger"},
|
||
{"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"}
|
||
]
|
||
}
|
||
|
||
# -------------------------------------------------------------------------
|
||
# 0.5 ПОСТУПЛЕНИЕ ДРАФТА ПРАВИЛ -> DIFF-ПРЕВЬЮ
|
||
# -------------------------------------------------------------------------
|
||
if msg_raw.startswith("action:save_draft_rules:::"):
|
||
new_draft_content = msg_raw.replace("action:save_draft_rules:::", "").strip()
|
||
rules_data = get_rules()
|
||
rules_list = rules_data.get("rules", []) if isinstance(rules_data, dict) else (rules_data if isinstance(rules_data, list) else [str(rules_data)])
|
||
baseline_rules = "\n".join([f"{i+1}. {r.get('rule_text', r) if isinstance(r, dict) else r}" for i, r in enumerate(rules_list)])
|
||
diff_html = _generate_prompt_diff_html(baseline_rules, new_draft_content)
|
||
|
||
db_set_session_state(session_id, "RULES_PREVIEW", {
|
||
"draft_text": new_draft_content,
|
||
"action": "MANUAL_EDIT_RULES",
|
||
"idle_turns": 0
|
||
})
|
||
|
||
preview_reply = f"Предпросмотр изменений базы знаний компании:\n\n{diff_html}\n\nПодтвердите сохранение или отмените действие."
|
||
db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1)
|
||
return preview_reply, db_get_chat_history(session_id), {
|
||
"type": "PROMPT_PREVIEW",
|
||
"raw_draft": new_draft_content,
|
||
"baseline_prompt": baseline_rules,
|
||
"diff_html": diff_html,
|
||
"buttons": [
|
||
{"label": "Подтвердить", "value": "подтверждаю сохранение правил", "style": "primary"},
|
||
{"label": "Отменить", "value": "отмена", "style": "danger"},
|
||
{"label": "✏️ Редактировать", "value": "action:open_rules_editor", "style": "secondary"}
|
||
]
|
||
}
|
||
|
||
if not session_state:
|
||
return None
|
||
|
||
state_type = session_state.get("state_type")
|
||
state_data = session_state.get("data_json") or {}
|
||
if not isinstance(state_data, dict):
|
||
state_data = {}
|
||
|
||
# -------------------------------------------------------------------------
|
||
# 1. ПОДТВЕРЖДЕНИЕ ПРОМПТА
|
||
# -------------------------------------------------------------------------
|
||
if state_type == "PROMPT_PREVIEW":
|
||
is_confirm = msg_lower in ["подтверждаю", "да", "сохраняй", "применить", "ок", "подтвердить"]
|
||
is_cancel = msg_lower in ["отмена", "отменить", "нет", "отклонить"]
|
||
|
||
if is_confirm:
|
||
draft_text = state_data.get("draft_text", "")
|
||
if draft_text:
|
||
save_full_prompt_draft(draft_text, prompt_name="main_agent")
|
||
|
||
close_tool_session_and_cleanup(session_id, close_reason="PROMPT_APPLIED_SUCCESSFULLY")
|
||
db_clear_session_state(session_id)
|
||
|
||
reply = "✅ Системный промпт успешно сохранен и применен в базе данных."
|
||
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
|
||
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
|
||
return reply, db_get_chat_history(session_id), None
|
||
|
||
elif is_cancel:
|
||
close_tool_session_and_cleanup(session_id, close_reason="PROMPT_EDIT_CANCELLED")
|
||
db_clear_session_state(session_id)
|
||
|
||
reply = "❌ Изменения системного промпта отменены."
|
||
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
|
||
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
|
||
return reply, db_get_chat_history(session_id), None
|
||
|
||
# -------------------------------------------------------------------------
|
||
# 2. ПОДТВЕРЖДЕНИЕ ПРАВИЛ
|
||
# -------------------------------------------------------------------------
|
||
elif state_type == "RULES_PREVIEW":
|
||
is_confirm = "сохранение правил" in msg_lower or msg_lower in ["подтверждаю", "да", "сохраняй", "применить"]
|
||
is_cancel = msg_lower in ["отмена", "отменить", "нет"]
|
||
|
||
if is_confirm:
|
||
draft_text = state_data.get("draft_text", "")
|
||
lines = [l.strip() for l in draft_text.splitlines() if l.strip()]
|
||
for line in lines:
|
||
clean_line = line
|
||
if line[0].isdigit() and "." in line[:5]:
|
||
clean_line = line.split(".", 1)[1].strip()
|
||
add_rule(clean_line)
|
||
|
||
close_tool_session_and_cleanup(session_id, close_reason="RULES_SAVED_SUCCESS")
|
||
db_clear_session_state(session_id)
|
||
|
||
reply = "✅ База знаний и правила компании успешно сохранены в базе данных."
|
||
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
|
||
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
|
||
return reply, db_get_chat_history(session_id), None
|
||
|
||
elif is_cancel:
|
||
close_tool_session_and_cleanup(session_id, close_reason="RULES_EDIT_CANCELLED")
|
||
db_clear_session_state(session_id)
|
||
|
||
reply = "❌ Изменение правил компании отменено."
|
||
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
|
||
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
|
||
return reply, db_get_chat_history(session_id), None
|
||
|
||
return None
|
||
```
|
||
|
||
## File: `./modules/web_api/llm/core/context_manager.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/llm/core/context_manager.py
|
||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||
MODULE: web_api / llm / core
|
||
ROLE: Интеллектуальный менеджер контекста: разделение служебных транзакций
|
||
инструментов и содержательного диалога с сохранением Topic Drift.
|
||
===============================================================================
|
||
"""
|
||
|
||
import logging
|
||
from typing import Dict, Any, Optional, List
|
||
|
||
from ..db.connection import get_db_connection
|
||
from ..db_tools import (
|
||
db_save_chat_message,
|
||
db_get_chat_history,
|
||
db_purge_ephemeral_messages,
|
||
db_clear_session_state,
|
||
db_set_session_state
|
||
)
|
||
|
||
logger = logging.getLogger("CONTEXT_MANAGER")
|
||
|
||
|
||
def save_tool_interaction(session_id: str, user_content: str, assistant_reply: str) -> None:
|
||
"""
|
||
Сохраняет синхронную служебную пару инструмента (и вопрос, и ответ = 1).
|
||
При очистке удалятся оба сообщения, не оставляя сирот.
|
||
"""
|
||
db_save_chat_message(session_id, "user", user_content, is_ephemeral=1)
|
||
db_save_chat_message(session_id, "assistant", assistant_reply, is_ephemeral=1)
|
||
|
||
|
||
def save_dialog_interaction(session_id: str, user_content: str, assistant_reply: str) -> None:
|
||
"""
|
||
Сохраняет содержательный диалог пользователя и ассистента (и вопрос, и ответ = 0).
|
||
Эти сообщения остаются в истории навсегда (включая Topic Drift).
|
||
"""
|
||
db_save_chat_message(session_id, "user", user_content, is_ephemeral=0)
|
||
db_save_chat_message(session_id, "assistant", assistant_reply, is_ephemeral=0)
|
||
|
||
|
||
def mark_last_user_message_ephemeral(session_id: str) -> None:
|
||
"""Помечает последнее сообщение пользователя как эфемерное при активации инструмента."""
|
||
with get_db_connection() as conn:
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
UPDATE chat_messages
|
||
SET is_ephemeral = 1
|
||
WHERE id = (SELECT MAX(id) FROM chat_messages WHERE session_id = ? AND role = 'user')
|
||
""", (session_id,))
|
||
conn.commit()
|
||
|
||
|
||
def close_tool_session_and_cleanup(session_id: str, close_reason: str = "COMPLETED") -> int:
|
||
"""
|
||
Закрывает сессию инструмента и удаляет ТОЛЬКО служебные карточки/команды.
|
||
Весь содержательный диалог сохраняется.
|
||
"""
|
||
db_clear_session_state(session_id)
|
||
deleted_count = db_purge_ephemeral_messages(session_id)
|
||
logger.info(f"[ContextManager] Сессия инструмента закрыта ({close_reason}). Очищено служебных сообщений: {deleted_count}")
|
||
return deleted_count
|
||
```
|
||
|
||
## File: `./modules/web_api/llm/core/ollama_client.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/llm/core/ollama_client.py
|
||
ROLE: Транспортный клиент HTTP взаимодействия с локальным API Ollama.
|
||
===============================================================================
|
||
"""
|
||
|
||
# ANCHOR[OLLAMA_CLIENT_IMPORTS]
|
||
import json
|
||
import urllib.request
|
||
import urllib.error
|
||
import logging
|
||
from typing import Dict, Any, Optional
|
||
|
||
logger = logging.getLogger("OLLAMA_CLIENT")
|
||
|
||
OLLAMA_URL = "http://10.121.17.227:11434/api/chat"
|
||
TEXT_MODEL = "qwen2.5:14b"
|
||
VISION_MODEL = "qwen2.5vl:7b-q8_0"
|
||
|
||
LLM_OPTIONS = {
|
||
"num_predict": 8192,
|
||
"num_ctx": 8192,
|
||
"temperature": 0.0,
|
||
"repeat_penalty": 1.0,
|
||
"presence_penalty": 0.0,
|
||
"top_p": 0.9
|
||
}
|
||
|
||
|
||
# ANCHOR[OLLAMA_REQUEST_DISPATCHER]
|
||
def call_ollama_chat(
|
||
messages: list,
|
||
tools: Optional[list] = None,
|
||
is_vision: bool = False,
|
||
timeout: int = 120
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Отправляет подготовленный массив сообщений в API Ollama.
|
||
Возвращает разобранный словарь сообщения ответа или генерирует исключение.
|
||
"""
|
||
model_name = VISION_MODEL if is_vision else TEXT_MODEL
|
||
payload = {
|
||
"model": model_name,
|
||
"messages": messages,
|
||
"stream": False,
|
||
"options": LLM_OPTIONS
|
||
}
|
||
|
||
if tools and not is_vision:
|
||
payload["tools"] = tools
|
||
|
||
req = urllib.request.Request(
|
||
OLLAMA_URL,
|
||
data=json.dumps(payload).encode("utf-8"),
|
||
headers={"Content-Type": "application/json"}
|
||
)
|
||
|
||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||
res_data = json.loads(response.read().decode("utf-8"))
|
||
return res_data.get("message", {})
|
||
```
|
||
|
||
## File: `./modules/web_api/llm/core/tool_injector.py`
|
||
```py
|
||
"""
|
||
===============================================================================
|
||
FILE: modules/web_api/llm/core/tool_injector.py
|
||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||
MODULE: web_api / llm / core
|
||
ROLE: Базовая санитарная очистка вывода и тегов инструментов.
|
||
===============================================================================
|
||
"""
|
||
|
||
import re
|
||
import logging
|
||
from typing import List, Dict, Any
|
||
|
||
logger = logging.getLogger("TOOL_INJECTOR")
|
||
|
||
|
||
def clean_raw_tool_tags(text: str) -> str:
|
||
"""Удаляет сырые теги вызова инструментов и системный шум."""
|
||
if not text:
|
||
return ""
|
||
cleaned = re.sub(r'<tool_call>.*?</tool_call>', '', text, flags=re.DOTALL)
|
||
cleaned = re.sub(r'<\|.*?\|>', '', cleaned)
|
||
return cleaned.strip()
|
||
|
||
|
||
def clean_output(text: str) -> str:
|
||
"""Очищает маркеры форматирования."""
|
||
return text.strip() if text else ""
|
||
|
||
|
||
def inject_tools_if_needed(user_message: str, raw_reply: str, existing_tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
"""
|
||
Модель управляется через TOOLS_SCHEMA и системный контекст.
|
||
Любые синтетические перехваты текста регулярными выражениями отключены согласно ROADMAP.
|
||
"""
|
||
return existing_tool_calls
|
||
```
|
||
|
||
## File: `./modules/web_api/static/index.html`
|
||
```html
|
||
<!DOCTYPE html>
|
||
<html lang="ru" class="h-full bg-slate-100">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>SCUD Orion AI Assistant</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||
<link rel="icon" href="/favicon.ico" type="image/x-icon">
|
||
<style>
|
||
/* Запрещаем браузеру насильно удерживать скролл внизу при появлении ответа */
|
||
* {
|
||
overflow-anchor: none !important;
|
||
}
|
||
|
||
#chat-messages-container, main {
|
||
overflow-anchor: none !important;
|
||
}
|
||
|
||
/* ⭐️ Воздух снизу для возможности поднятия вопроса на самый верх */
|
||
#chat-messages-container {
|
||
padding-bottom: clamp(400px, 85vh, 900px) !important;
|
||
}
|
||
|
||
/* Принудительное увеличение шрифта сообщений чата */
|
||
#chat-messages-container .message-content,
|
||
#chat-messages-container .text-xs,
|
||
#chat-messages-container .text-sm {
|
||
font-size: 14.5px !important;
|
||
line-height: 1.6 !important;
|
||
}
|
||
#chat-messages-container pre,
|
||
#chat-messages-container code {
|
||
font-size: 13.5px !important;
|
||
}
|
||
/* Смещение для точной прокрутки под фиксированный заголовок */
|
||
.user-chat-bubble {
|
||
scroll-margin-top: 24px !important;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body class="h-full flex flex-col font-sans antialiased text-slate-800 bg-slate-100 selection:bg-indigo-500 selection:text-white">
|
||
|
||
<div id="app-container" class="flex-1 flex overflow-hidden w-full h-full">
|
||
|
||
<!-- ЛЕВАЯ КОЛОНКА (ДИНАМИЧЕСКИЙ САЙДБАР 5-ХАБОВ) -->
|
||
<aside class="w-80 md:w-96 bg-white border-r border-slate-200 flex flex-col shrink-0 h-full shadow-sm z-10 select-none">
|
||
|
||
<!-- ДИНАМИЧЕСКИЙ ТАБ-БАР ХАБОВ И ПОДВКЛАДОК -->
|
||
<div id="sidebar-dynamic-header" class="shrink-0 bg-slate-50 border-b border-slate-200"></div>
|
||
|
||
<!-- ДИНАМИЧЕСКИЙ КОНТЕНТНЫЙ СЛОТ -->
|
||
<div id="sidebar-dynamic-content" class="flex-1 overflow-y-auto p-2 flex flex-col gap-2">
|
||
<div id="tasks-list-container" class="flex-1 flex flex-col gap-2">
|
||
<div class="text-center py-10 text-xs text-slate-400">
|
||
<i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка...
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ПОДВАЛ САЙДБАРА: ПРОФИЛЬ, НАСТРОЙКИ, АДМИНКА И ВЫХОД -->
|
||
<div class="p-3 border-t border-slate-200 bg-slate-50 flex items-center justify-between shrink-0">
|
||
<div class="flex items-center gap-2.5 min-w-0">
|
||
<div class="w-8 h-8 rounded-full bg-indigo-600 text-white flex items-center justify-center font-bold text-xs shadow-sm shrink-0">
|
||
<i class="fa-solid fa-user"></i>
|
||
</div>
|
||
<div class="min-w-0 flex flex-col">
|
||
<span id="user-display-name" class="text-xs font-bold text-slate-800 truncate">Пользователь</span>
|
||
<span id="user-display-role" class="text-[10px] text-slate-400">Оператор</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-1">
|
||
<button id="admin-panel-btn" type="button" onclick="openAdminModal()" class="hidden p-1.5 text-slate-400 hover:text-indigo-600 hover:bg-slate-200 rounded-lg transition" title="Управление пользователями">
|
||
<i class="fa-solid fa-users-gear text-sm"></i>
|
||
</button>
|
||
|
||
<button type="button" onclick="openProfileModal()" class="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-200 rounded-lg transition" title="Сменить пароль">
|
||
<i class="fa-solid fa-gear text-sm"></i>
|
||
</button>
|
||
|
||
<button type="button" onclick="AuthManager.logout()" class="p-1.5 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition" title="Выйти из системы">
|
||
<i class="fa-solid fa-arrow-right-from-bracket text-sm"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- ПРАВАЯ ОБЛАСТЬ (ЧАТ И ИИ-АССИСТЕНТ) -->
|
||
<main class="flex-1 flex flex-col h-full min-w-0 bg-slate-50 relative">
|
||
<header class="h-14 bg-white border-b border-slate-200 px-4 flex items-center justify-between shrink-0 shadow-sm z-10">
|
||
<div class="flex items-center gap-2.5">
|
||
<div class="w-7 h-7 rounded-lg bg-indigo-600 text-white flex items-center justify-center shadow-sm">
|
||
<i class="fa-solid fa-robot text-xs"></i>
|
||
</div>
|
||
<div>
|
||
<div class="flex items-center gap-2">
|
||
<h1 class="text-sm font-bold text-slate-800">SCUD Orion AI Assistant</h1>
|
||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">Online</span>
|
||
</div>
|
||
<p class="text-[10px] text-slate-400">Система интеллектуального аудита и контроля СКУД / 1С</p>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- ЛЕНТА ЧАТА -->
|
||
<div id="chat-messages-container" class="flex-1 overflow-y-auto p-4 md:p-6 flex flex-col gap-4">
|
||
<div class="flex gap-3 max-w-4xl mx-auto w-full">
|
||
<div class="w-7 h-7 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
|
||
<i class="fa-solid fa-robot text-xs"></i>
|
||
</div>
|
||
<div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm">
|
||
<div class="text-[10px] font-bold text-indigo-600 uppercase tracking-wider mb-1">ИИ-ассистент SCUD Orion AI</div>
|
||
<div class="text-xs text-slate-700 leading-relaxed">
|
||
Привет! Вы можете задавать вопросы ассистенту, управлять системным промптом, сверять кадровые нестыковки СКУД и 1С или формировать срезы и отчеты.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- БЕЙДЖ ПРИКРЕПЛЕННОГО ФАЙЛА -->
|
||
<div id="file-attachment-preview" class="hidden max-w-4xl mx-auto w-full px-4 pt-2">
|
||
<div class="inline-flex items-center gap-2 px-3 py-1 bg-indigo-50 border border-indigo-200 rounded-xl text-xs text-indigo-700 shadow-sm">
|
||
<i class="fa-solid fa-file text-indigo-600"></i>
|
||
<span id="file-attachment-name" class="font-medium truncate max-w-xs"></span>
|
||
<button type="button" onclick="clearAttachedFile()" class="text-indigo-400 hover:text-rose-600 ml-1">
|
||
<i class="fa-solid fa-xmark"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Контейнер строки ввода сообщения -->
|
||
<div class="px-4 py-2 bg-white border-t border-slate-200">
|
||
<div id="chat-input-box" class="max-w-4xl mx-auto w-full flex items-center gap-2 bg-slate-50 border border-slate-300 rounded-xl px-2.5 py-1 transition focus-within:border-indigo-500 focus-within:bg-white focus-within:ring-1 focus-within:ring-indigo-100">
|
||
<!-- Скрепка файлов -->
|
||
<button type="button" onclick="document.getElementById('file-upload-input').click()" class="text-slate-400 hover:text-indigo-600 p-1 transition shrink-0">
|
||
<i class="fa-solid fa-paperclip text-xs"></i>
|
||
</button>
|
||
<input type="file" id="file-upload-input" class="hidden" />
|
||
|
||
<!-- Поле ввода -->
|
||
<textarea id="user-input" rows="1" placeholder="Команда, вопрос (Enter - отправить, Shift+Enter - перенос строки)..."
|
||
class="flex-1 bg-transparent border-0 focus:outline-none text-xs text-slate-800 resize-none py-0 leading-5" style="height: 24px; line-height: 24px;"></textarea>
|
||
|
||
<!-- Кнопка отправки -->
|
||
<button type="button" onclick="window.sendMessage()" class="w-6 h-6 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white flex items-center justify-center shrink-0 shadow-sm transition">
|
||
<i class="fa-solid fa-paper-plane text-[10px]"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
<!-- МОДАЛЬНОЕ ОКНО: УДАЛЕННЫЙ СОТРУДНИК -->
|
||
<div id="remote-worker-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
|
||
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-md w-full p-6 flex flex-col gap-4">
|
||
<div class="flex items-center justify-between">
|
||
<h3 id="remote-modal-title" class="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||
<i class="fa-solid fa-house-laptop text-emerald-600"></i>
|
||
<span>Параметры удаленной работы</span>
|
||
</h3>
|
||
<button type="button" onclick="closeRemoteWorkerModal()" class="text-slate-400 hover:text-slate-600">
|
||
<i class="fa-solid fa-xmark"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<form id="remote-worker-form" onsubmit="handleRemoteWorkerSubmit(event)" class="flex flex-col gap-3">
|
||
<input type="hidden" id="rw-mode" value="ADD" />
|
||
|
||
<div>
|
||
<label class="block text-[11px] font-bold text-slate-600 mb-1">ФИО сотрудника:</label>
|
||
<input type="text" id="rw-fio" required placeholder="Например: Иванов Иван Иванович"
|
||
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-slate-50" />
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Подразделение:</label>
|
||
<input type="text" id="rw-dept" placeholder="Все"
|
||
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-slate-50" />
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Дата начала:</label>
|
||
<input type="date" id="rw-date-from"
|
||
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-slate-50 text-slate-700" />
|
||
<span class="text-[10px] text-slate-400 mt-0.5 block">Пусто = с сегодняшнего дня</span>
|
||
</div>
|
||
<div>
|
||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Дата окончания:</label>
|
||
<input type="date" id="rw-date-to"
|
||
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-slate-50 text-slate-700" />
|
||
<span class="text-[10px] text-slate-400 mt-0.5 block">Пусто = бессрочно</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="rw-error" class="text-[11px] font-semibold text-rose-600 hidden"></div>
|
||
|
||
<div class="flex items-center justify-end gap-2 mt-2 pt-2 border-t border-slate-100">
|
||
<button type="button" onclick="closeRemoteWorkerModal()"
|
||
class="px-3.5 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium transition">
|
||
Отмена
|
||
</button>
|
||
<button type="submit" id="rw-submit-btn"
|
||
class="px-4 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold shadow transition">
|
||
Сохранить
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- МОДАЛЬНОЕ ОКНО: МЕСТНАЯ КОМАНДИРОВКА И ИНОЕ С АВТОКОМПЛИТОМ -->
|
||
<div id="manual-absence-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
|
||
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-md w-full p-6 flex flex-col gap-4">
|
||
<div class="flex items-center justify-between">
|
||
<h3 id="manual-absence-modal-title" class="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||
<i class="fa-solid fa-location-dot text-indigo-600"></i>
|
||
<span>Добавление в реестр</span>
|
||
</h3>
|
||
<button type="button" onclick="closeManualAbsenceModal()" class="text-slate-400 hover:text-slate-600">
|
||
<i class="fa-solid fa-xmark"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="flex flex-col gap-3">
|
||
<!-- Поле ФИО с автодополнением по 1С -->
|
||
<div class="relative">
|
||
<label class="block text-[11px] font-bold text-slate-600 mb-1">ФИО сотрудника (автоподбор из 1С):</label>
|
||
<input type="text" id="manual-absence-fio-input" autocomplete="off" placeholder="Начните вводить фамилию..."
|
||
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50" />
|
||
<div id="manual-absence-suggestions" class="hidden absolute left-0 right-0 top-full mt-1 bg-white border border-slate-300 rounded-lg shadow-xl z-30 max-h-48 overflow-y-auto"></div>
|
||
</div>
|
||
|
||
<input type="hidden" id="manual-absence-dept" />
|
||
<input type="hidden" id="manual-absence-pos" />
|
||
|
||
<!-- Выпадающий список причин (только для "Иное") -->
|
||
<div id="manual-absence-reason-block" class="hidden">
|
||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Причина отсутствия:</label>
|
||
<select id="manual-absence-reason-select" class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50"></select>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Начало:</label>
|
||
<input type="date" id="manual-absence-start-date"
|
||
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-700" />
|
||
<span class="text-[10px] text-slate-400 mt-0.5 block">Пусто = сегодня</span>
|
||
</div>
|
||
<div>
|
||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Окончание:</label>
|
||
<input type="date" id="manual-absence-end-date"
|
||
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-700" />
|
||
<span class="text-[10px] text-slate-400 mt-0.5 block">По умолчанию: сегодня</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-center justify-end gap-2 mt-2 pt-2 border-t border-slate-100">
|
||
<button type="button" onclick="closeManualAbsenceModal()"
|
||
class="px-3.5 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium transition">
|
||
Отмена
|
||
</button>
|
||
<button type="button" onclick="submitManualAbsence()"
|
||
class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow transition">
|
||
Сохранить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- МОДАЛЬНОЕ ОКНО АВТОРИЗАЦИИ -->
|
||
<div id="auth-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
|
||
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-sm w-full p-6 flex flex-col gap-4">
|
||
<div class="flex items-center gap-3">
|
||
<div class="w-10 h-10 rounded-xl bg-indigo-600 text-white flex items-center justify-center font-bold text-lg shadow">
|
||
<i class="fa-solid fa-shield-halved"></i>
|
||
</div>
|
||
<div>
|
||
<h2 class="text-sm font-bold text-slate-800">Авторизация в системе</h2>
|
||
<p class="text-[11px] text-slate-400">SCUD Orion AI Security Access</p>
|
||
</div>
|
||
</div>
|
||
|
||
<form id="auth-form" onsubmit="handleLoginSubmit(event)" class="flex flex-col gap-3">
|
||
<div>
|
||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Имя пользователя:</label>
|
||
<input type="text" id="auth-username" required class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50" placeholder="Логин" />
|
||
</div>
|
||
<div>
|
||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Пароль:</label>
|
||
<input type="password" id="auth-password" required class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50" placeholder="••••••••" />
|
||
</div>
|
||
<div id="auth-error" class="text-[11px] font-semibold text-rose-600 hidden"></div>
|
||
<button type="submit" class="w-full py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-bold rounded-lg text-xs shadow transition mt-1">
|
||
Войти в систему
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- МОДАЛЬНОЕ ОКНО СМЕНЫ ПАРОЛЯ -->
|
||
<div id="profile-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
|
||
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-sm w-full p-6 flex flex-col gap-4">
|
||
<div class="flex items-center justify-between">
|
||
<h3 class="text-sm font-bold text-slate-800 flex items-center gap-1.5">
|
||
<i class="fa-solid fa-key text-indigo-600"></i> Смена пароля
|
||
</h3>
|
||
<button onclick="closeProfileModal()" class="text-slate-400 hover:text-slate-600"><i class="fa-solid fa-xmark"></i></button>
|
||
</div>
|
||
<form onsubmit="handleChangePassword(event)" class="flex flex-col gap-3">
|
||
<div>
|
||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Текущий пароль:</label>
|
||
<input type="password" id="old-pass" required class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500" />
|
||
</div>
|
||
<div>
|
||
<label class="block text-[11px] font-bold text-slate-600 mb-1">Новый пароль (мин. 4 симв.):</label>
|
||
<input type="password" id="new-pass" required class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500" />
|
||
</div>
|
||
<div id="pass-error" class="text-[11px] font-semibold text-rose-600 hidden"></div>
|
||
<div class="flex items-center justify-end gap-2 mt-2">
|
||
<button type="button" onclick="closeProfileModal()" class="px-3 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium">Отмена</button>
|
||
<button type="submit" class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow transition">Сохранить</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- МОДАЛЬНОЕ ОКНО ПАНЕЛИ АДМИНИСТРАТОРА -->
|
||
<div id="admin-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
|
||
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-lg w-full p-6 flex flex-col gap-4 max-h-[85vh]">
|
||
<div class="flex items-center justify-between">
|
||
<h3 class="text-sm font-bold text-slate-800 flex items-center gap-1.5">
|
||
<i class="fa-solid fa-users-gear text-indigo-600"></i> Управление учетными записями
|
||
</h3>
|
||
<button onclick="closeAdminModal()" class="text-slate-400 hover:text-slate-600"><i class="fa-solid fa-xmark"></i></button>
|
||
</div>
|
||
|
||
<form onsubmit="handleCreateUser(event)" class="p-3 bg-slate-50 border border-slate-200 rounded-xl flex flex-col gap-2">
|
||
<span class="text-xs font-bold text-slate-700">Создать нового пользователя:</span>
|
||
<div class="grid grid-cols-2 gap-2">
|
||
<input type="text" id="new-user-username" placeholder="Логин" required class="text-xs px-2.5 py-1.5 border border-slate-300 rounded-lg bg-white" />
|
||
<input type="password" id="new-user-password" placeholder="Пароль" required class="text-xs px-2.5 py-1.5 border border-slate-300 rounded-lg bg-white" />
|
||
</div>
|
||
<input type="text" id="new-user-fullname" placeholder="ФИО" class="text-xs px-2.5 py-1.5 border border-slate-300 rounded-lg bg-white" />
|
||
<div class="flex items-center justify-between">
|
||
<label class="flex items-center gap-1.5 text-xs text-slate-600 cursor-pointer">
|
||
<input type="checkbox" id="new-user-admin" class="rounded border-slate-300 text-indigo-600" />
|
||
<span>Права администратора</span>
|
||
</label>
|
||
<button type="submit" class="px-3 py-1 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow">
|
||
Создать
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
<div class="flex-1 overflow-y-auto flex flex-col gap-1.5" id="admin-users-list">
|
||
<div class="text-center py-4 text-xs text-slate-400">Загрузка пользователей...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ПОДКЛЮЧЕНИЕ СКРИПТОВ -->
|
||
<script src="/static/js/auth.js?v=2.5.7"></script>
|
||
<script src="/static/js/tasks.js?v=2.5.7"></script>
|
||
<script src="/static/js/manual_absences.js?v=2.5.7"></script>
|
||
<script src="/static/js/sidebar.js?v=2.5.7"></script>
|
||
<script src="/static/js/chat/task_widget.js?v=2.5.7"></script>
|
||
<script src="/static/js/chat/core.js?v=2.5.7"></script>
|
||
<script src="/static/js/app.js?v=2.5.7"></script>
|
||
|
||
<script>
|
||
function showAuthModal() {
|
||
const modal = document.getElementById("auth-modal");
|
||
if (modal) modal.classList.remove("hidden");
|
||
}
|
||
|
||
function hideAuthModal() {
|
||
const modal = document.getElementById("auth-modal");
|
||
if (modal) modal.classList.add("hidden");
|
||
}
|
||
|
||
function updateUIState() {
|
||
const nameEl = document.getElementById("user-display-name");
|
||
const roleEl = document.getElementById("user-display-role");
|
||
const adminBtn = document.getElementById("admin-panel-btn");
|
||
|
||
if (nameEl) nameEl.innerText = AuthManager.getFullName();
|
||
if (roleEl) roleEl.innerText = AuthManager.isAdmin() ? "Администратор" : "Оператор";
|
||
|
||
if (adminBtn) {
|
||
if (AuthManager.isAdmin()) {
|
||
adminBtn.classList.remove("hidden");
|
||
} else {
|
||
adminBtn.classList.add("hidden");
|
||
}
|
||
}
|
||
}
|
||
|
||
async function handleLoginSubmit(e) {
|
||
e.preventDefault();
|
||
const uInput = document.getElementById("auth-username");
|
||
const pInput = document.getElementById("auth-password");
|
||
const errEl = document.getElementById("auth-error");
|
||
errEl.classList.add("hidden");
|
||
|
||
try {
|
||
const res = await fetch("/api/v1/auth/login", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
username: uInput.value.trim(),
|
||
password: pInput.value
|
||
})
|
||
});
|
||
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
AuthManager.setSession(data.token, data.username, data.full_name, data.is_admin, data.user_id);
|
||
hideAuthModal();
|
||
updateUIState();
|
||
if (window.SidebarManager) SidebarManager.setHub('TASKS');
|
||
} else {
|
||
const err = await res.json();
|
||
errEl.innerText = err.detail || "Неверный логин или пароль";
|
||
errEl.classList.remove("hidden");
|
||
}
|
||
} catch (err) {
|
||
errEl.innerText = "Ошибка соединения с сервером";
|
||
errEl.classList.remove("hidden");
|
||
}
|
||
}
|
||
|
||
function openProfileModal() {
|
||
document.getElementById("profile-modal").classList.remove("hidden");
|
||
}
|
||
function closeProfileModal() {
|
||
document.getElementById("profile-modal").classList.add("hidden");
|
||
}
|
||
async function handleChangePassword(e) {
|
||
e.preventDefault();
|
||
const oldP = document.getElementById("old-pass").value;
|
||
const newP = document.getElementById("new-pass").value;
|
||
const errEl = document.getElementById("pass-error");
|
||
errEl.classList.add("hidden");
|
||
|
||
try {
|
||
const res = await fetch("/api/v1/auth/change-password", {
|
||
method: "POST",
|
||
headers: AuthManager.getAuthHeaders(),
|
||
body: JSON.stringify({ old_password: oldP, new_password: newP })
|
||
});
|
||
if (res.ok) {
|
||
alert("Пароль успешно изменен");
|
||
closeProfileModal();
|
||
} else {
|
||
const err = await res.json();
|
||
errEl.innerText = err.detail || "Ошибка изменения пароля";
|
||
errEl.classList.remove("hidden");
|
||
}
|
||
} catch (e) {
|
||
errEl.innerText = "Ошибка сети";
|
||
errEl.classList.remove("hidden");
|
||
}
|
||
}
|
||
|
||
function openAdminModal() {
|
||
document.getElementById("admin-modal").classList.remove("hidden");
|
||
loadAdminUsers();
|
||
}
|
||
function closeAdminModal() {
|
||
document.getElementById("admin-modal").classList.add("hidden");
|
||
}
|
||
async function loadAdminUsers() {
|
||
const listEl = document.getElementById("admin-users-list");
|
||
listEl.innerHTML = `<div class="text-center py-4 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка...</div>`;
|
||
try {
|
||
const res = await fetch("/api/v1/admin/users", { headers: AuthManager.getAuthHeaders() });
|
||
if (res.ok) {
|
||
const users = await res.json();
|
||
listEl.innerHTML = users.map(u => `
|
||
<div class="flex items-center justify-between p-2.5 bg-slate-50 border border-slate-200 rounded-xl text-xs">
|
||
<div>
|
||
<div class="font-bold text-slate-800">${escapeHtml(u.full_name || u.username)} <span class="text-slate-400 font-mono text-[10px]">(${escapeHtml(u.username)})</span></div>
|
||
<div class="text-[10px] ${u.is_admin ? 'text-indigo-600 font-bold' : 'text-slate-400'}">${u.is_admin ? 'Администратор' : 'Оператор'}</div>
|
||
</div>
|
||
<button onclick="deleteAdminUser(${u.id}, '${escapeHtml(u.username)}')" class="text-slate-400 hover:text-rose-600 p-1.5" title="Удалить">
|
||
<i class="fa-solid fa-trash-can"></i>
|
||
</button>
|
||
</div>
|
||
`).join('');
|
||
} else {
|
||
listEl.innerHTML = `<div class="text-center py-4 text-xs text-rose-500">Ошибка загрузки пользователей</div>`;
|
||
}
|
||
} catch (e) {
|
||
listEl.innerHTML = `<div class="text-center py-4 text-xs text-rose-500">Ошибка сети</div>`;
|
||
}
|
||
}
|
||
|
||
async function handleCreateUser(e) {
|
||
e.preventDefault();
|
||
const u = document.getElementById("new-user-username").value;
|
||
const p = document.getElementById("new-user-password").value;
|
||
const f = document.getElementById("new-user-fullname").value;
|
||
const a = document.getElementById("new-user-admin").checked;
|
||
|
||
try {
|
||
const res = await fetch("/api/v1/admin/users", {
|
||
method: "POST",
|
||
headers: AuthManager.getAuthHeaders(),
|
||
body: JSON.stringify({ username: u, password: p, full_name: f, is_admin: a })
|
||
});
|
||
if (res.ok) {
|
||
document.getElementById("new-user-username").value = "";
|
||
document.getElementById("new-user-password").value = "";
|
||
document.getElementById("new-user-fullname").value = "";
|
||
document.getElementById("new-user-admin").checked = false;
|
||
loadAdminUsers();
|
||
} else {
|
||
const err = await res.json();
|
||
alert(err.detail || "Ошибка создания пользователя");
|
||
}
|
||
} catch (e) {
|
||
alert("Ошибка сети");
|
||
}
|
||
}
|
||
|
||
async function deleteAdminUser(id, username) {
|
||
if (!confirm(`Удалить пользователя ${username}?`)) return;
|
||
try {
|
||
const res = await fetch(`/api/v1/admin/users/${id}`, {
|
||
method: "DELETE",
|
||
headers: AuthManager.getAuthHeaders()
|
||
});
|
||
if (res.ok) {
|
||
loadAdminUsers();
|
||
} else {
|
||
const err = await res.json();
|
||
alert(err.detail || "Ошибка удаления");
|
||
}
|
||
} catch (e) {
|
||
alert("Ошибка сети");
|
||
}
|
||
}
|
||
|
||
function dmyToYmd(str) {
|
||
if (!str) return "";
|
||
const parts = str.replace(/_/g, '.').split('.');
|
||
if (parts.length === 3) return `${parts[2]}-${parts[1].padStart(2, '0')}-${parts[0].padStart(2, '0')}`;
|
||
return "";
|
||
}
|
||
|
||
function ymdToDmy(str) {
|
||
if (!str) return "";
|
||
const parts = str.split('-');
|
||
if (parts.length === 3) return `${parts[2]}.${parts[1]}.${parts[0]}`;
|
||
return "";
|
||
}
|
||
|
||
function openRemoteWorkerModal(mode = 'ADD', fio = '', dept = 'Все', dateFrom = '', dateTo = '') {
|
||
const modal = document.getElementById("remote-worker-modal");
|
||
const titleEl = document.getElementById("remote-modal-title");
|
||
const modeInput = document.getElementById("rw-mode");
|
||
const fioInput = document.getElementById("rw-fio");
|
||
const deptInput = document.getElementById("rw-dept");
|
||
const fromInput = document.getElementById("rw-date-from");
|
||
const toInput = document.getElementById("rw-date-to");
|
||
const errEl = document.getElementById("rw-error");
|
||
|
||
errEl.classList.add("hidden");
|
||
modeInput.value = mode;
|
||
|
||
if (mode === 'EDIT') {
|
||
titleEl.innerHTML = `<i class="fa-solid fa-pen-to-square text-emerald-600"></i><span>Изменение сроков удаленки</span>`;
|
||
fioInput.value = fio;
|
||
fioInput.readOnly = true;
|
||
fioInput.classList.add("bg-slate-100", "text-slate-500", "cursor-not-allowed");
|
||
deptInput.value = dept || "Все";
|
||
deptInput.readOnly = true;
|
||
deptInput.classList.add("bg-slate-100", "text-slate-500", "cursor-not-allowed");
|
||
fromInput.value = dmyToYmd(dateFrom);
|
||
toInput.value = dmyToYmd(dateTo);
|
||
} else {
|
||
titleEl.innerHTML = `<i class="fa-solid fa-house-laptop text-emerald-600"></i><span>Добавление удаленщика</span>`;
|
||
fioInput.value = "";
|
||
fioInput.readOnly = false;
|
||
fioInput.classList.remove("bg-slate-100", "text-slate-500", "cursor-not-allowed");
|
||
deptInput.value = "Все";
|
||
deptInput.readOnly = false;
|
||
deptInput.classList.remove("bg-slate-100", "text-slate-500", "cursor-not-allowed");
|
||
|
||
const today = new Date().toISOString().split('T')[0];
|
||
fromInput.value = today;
|
||
toInput.value = "";
|
||
}
|
||
|
||
modal.classList.remove("hidden");
|
||
}
|
||
|
||
function closeRemoteWorkerModal() {
|
||
document.getElementById("remote-worker-modal").classList.add("hidden");
|
||
}
|
||
|
||
async function handleRemoteWorkerSubmit(e) {
|
||
e.preventDefault();
|
||
const mode = document.getElementById("rw-mode").value;
|
||
const fio = document.getElementById("rw-fio").value.trim();
|
||
const dept = document.getElementById("rw-dept").value.trim() || "Все";
|
||
const fromVal = ymdToDmy(document.getElementById("rw-date-from").value);
|
||
const toVal = ymdToDmy(document.getElementById("rw-date-to").value);
|
||
const errEl = document.getElementById("rw-error");
|
||
errEl.classList.add("hidden");
|
||
|
||
const method = (mode === 'EDIT') ? "PUT" : "POST";
|
||
const payload = (mode === 'EDIT')
|
||
? { fio: fio, date_from: fromVal, date_to: toVal }
|
||
: { fio: fio, department: dept, reason: "Удаленная работа", date_from: fromVal, date_to: toVal };
|
||
|
||
try {
|
||
const res = await fetch("/api/v1/remote-workers", {
|
||
method: method,
|
||
headers: AuthManager.getAuthHeaders(),
|
||
body: JSON.stringify(payload)
|
||
});
|
||
|
||
if (res.ok) {
|
||
closeRemoteWorkerModal();
|
||
if (window.SidebarManager) SidebarManager.renderContent();
|
||
} else {
|
||
const err = await res.json();
|
||
errEl.innerText = err.detail || "Ошибка сохранения";
|
||
errEl.classList.remove("hidden");
|
||
}
|
||
} catch (err) {
|
||
errEl.innerText = "Ошибка соединения с сервером";
|
||
errEl.classList.remove("hidden");
|
||
}
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
if (window.AuthManager && AuthManager.isAuthenticated()) {
|
||
updateUIState();
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<!-- Глобальная Drag-and-Drop зона на весь экран -->
|
||
<div id="global-drag-overlay"
|
||
class="fixed inset-0 bg-indigo-900/40 backdrop-blur-xs z-50 hidden flex items-center justify-center pointer-events-none transition-all duration-200">
|
||
<div class="bg-white border-2 border-dashed border-indigo-500 rounded-3xl p-10 flex flex-col items-center gap-3 shadow-2xl scale-100 transition-transform">
|
||
<div class="w-16 h-16 rounded-2xl bg-indigo-50 text-indigo-600 flex items-center justify-center text-3xl shadow-inner">
|
||
<i class="fa-solid fa-cloud-arrow-up animate-bounce"></i>
|
||
</div>
|
||
<div class="text-base font-bold text-slate-800">Перетащите файл в любую точку окна</div>
|
||
<div class="text-xs text-slate-500 font-medium">PDF-документы, сканы, отчеты или изображения</div>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
## File: `./modules/web_api/static/js/sidebar.js`
|
||
```js
|
||
/**
|
||
* ===============================================================================
|
||
* FILE: modules/web_api/static/js/sidebar.js
|
||
* ROLE: Контроллер левого сайдбара с 5-хабовой навигацией, реестрами (2x2),
|
||
* подробным описанием управления контекстом и интеграцией чата.
|
||
* ===============================================================================
|
||
*/
|
||
|
||
window.escapeHtml = function(str) {
|
||
if (str === null || str === undefined) return '';
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
};
|
||
|
||
window.SidebarManager = {
|
||
currentHub: 'TASKS',
|
||
currentSubTab: {
|
||
'REGISTRIES': 'REMOTE'
|
||
},
|
||
|
||
hubs: [
|
||
{ id: 'TASKS', label: 'Задачи', icon: 'fa-list-check' },
|
||
{ id: 'SNAPSHOTS', label: 'Срезы', icon: 'fa-camera' },
|
||
{ id: 'REGISTRIES', label: 'Реестры', icon: 'fa-address-book' },
|
||
{ id: 'PROMPT', label: 'Промпт', icon: 'fa-terminal' },
|
||
{ id: 'CONTEXT', label: 'Контекст', icon: 'fa-comments' }
|
||
],
|
||
|
||
// ⭐️ Сетка реестров 2x2
|
||
subTabs: {
|
||
'REGISTRIES': [
|
||
{ id: 'REMOTE', label: 'Удаленщики', icon: 'fa-house-laptop' },
|
||
{ id: 'EXCEPTIONS', label: 'Исключения', icon: 'fa-user-shield' },
|
||
{ id: 'LOCAL_TRIP', label: 'Мест. командир.', icon: 'fa-location-dot' },
|
||
{ id: 'OTHER', label: 'Иное', icon: 'fa-clipboard-list' }
|
||
]
|
||
},
|
||
|
||
init() {
|
||
this.renderHeader();
|
||
this.renderContent();
|
||
},
|
||
|
||
setHub(hubId) {
|
||
this.currentHub = hubId;
|
||
this.renderHeader();
|
||
this.renderContent();
|
||
},
|
||
|
||
setSubTab(subTabId) {
|
||
this.currentSubTab[this.currentHub] = subTabId;
|
||
this.renderHeader();
|
||
this.renderContent();
|
||
},
|
||
|
||
renderHeader() {
|
||
const headerContainer = document.getElementById("sidebar-dynamic-header");
|
||
if (!headerContainer) return;
|
||
|
||
// 1. Основные 5 Хабов
|
||
const hubsHtml = `
|
||
<div class="flex items-center border-b border-slate-200 bg-slate-50/80 px-1 pt-1.5 overflow-x-auto gap-0.5">
|
||
${this.hubs.map(h => {
|
||
const isActive = this.currentHub === h.id;
|
||
return `
|
||
<button onclick="SidebarManager.setHub('${h.id}')"
|
||
class="flex-1 py-1.5 px-1 flex flex-col items-center gap-1 border-b-2 font-bold text-[10px] transition ${
|
||
isActive
|
||
? 'border-indigo-600 text-indigo-600 bg-white rounded-t-lg shadow-sm'
|
||
: 'border-transparent text-slate-500 hover:text-slate-800 hover:bg-slate-100/60 rounded-t-lg'
|
||
}">
|
||
<i class="fa-solid ${h.icon} text-xs"></i>
|
||
<span class="truncate">${h.label}</span>
|
||
</button>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
`;
|
||
|
||
// 2. Подвкладки реестров (Сетка 2x2 в строгом соответствии с макетом)
|
||
let subTabsHtml = '';
|
||
if (this.subTabs[this.currentHub]) {
|
||
const currentActiveSub = this.currentSubTab[this.currentHub] || this.subTabs[this.currentHub][0].id;
|
||
subTabsHtml = `
|
||
<div class="grid grid-cols-2 gap-1.5 p-1.5 bg-slate-100/90 border-b border-slate-200">
|
||
${this.subTabs[this.currentHub].map(st => {
|
||
const isSubActive = currentActiveSub === st.id;
|
||
return `
|
||
<button onclick="SidebarManager.setSubTab('${st.id}')"
|
||
class="py-1 px-2 rounded-md text-[11px] font-semibold flex items-center justify-center gap-1.5 transition ${
|
||
isSubActive
|
||
? 'bg-white text-indigo-700 shadow-sm'
|
||
: 'text-slate-600 hover:text-slate-900 hover:bg-white/50'
|
||
}">
|
||
<i class="fa-solid ${st.icon} text-[10px]"></i>
|
||
<span>${st.label}</span>
|
||
</button>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
headerContainer.innerHTML = hubsHtml + subTabsHtml;
|
||
},
|
||
|
||
renderContent() {
|
||
const contentContainer = document.getElementById("sidebar-dynamic-content");
|
||
if (!contentContainer) return;
|
||
|
||
contentContainer.scrollTop = 0;
|
||
|
||
switch (this.currentHub) {
|
||
case 'TASKS':
|
||
this.renderTasksView(contentContainer);
|
||
break;
|
||
case 'SNAPSHOTS':
|
||
this.renderSnapshotsView(contentContainer);
|
||
break;
|
||
case 'REGISTRIES':
|
||
this.renderRegistriesView(contentContainer);
|
||
break;
|
||
case 'PROMPT':
|
||
this.renderPromptView(contentContainer);
|
||
break;
|
||
case 'CONTEXT':
|
||
this.renderContextView(contentContainer);
|
||
break;
|
||
default:
|
||
contentContainer.innerHTML = `<div class="p-4 text-xs text-slate-400 text-center">Раздел в разработке</div>`;
|
||
}
|
||
},
|
||
|
||
// =========================================================================
|
||
// ХАБ 1: ЗАДАЧИ
|
||
// =========================================================================
|
||
renderTasksView(container) {
|
||
container.innerHTML = `
|
||
<div id="tasks-list-container" class="flex-1 flex flex-col gap-2">
|
||
<div class="text-center py-10 text-xs text-slate-400">
|
||
<i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка задач...
|
||
</div>
|
||
</div>
|
||
`;
|
||
if (window.loadTasks) {
|
||
window.loadTasks();
|
||
}
|
||
},
|
||
|
||
// =========================================================================
|
||
// ХАБ 2: СРЕЗЫ СКУД
|
||
// =========================================================================
|
||
async renderSnapshotsView(container) {
|
||
container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка срезов...</div>`;
|
||
try {
|
||
const res = await fetch("/api/v1/snapshots", { headers: AuthManager.getAuthHeaders() });
|
||
const data = res.ok ? await res.json() : { snapshots: [] };
|
||
const snaps = data.snapshots || [];
|
||
|
||
if (snaps.length === 0) {
|
||
container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400">Срезы СКУД не найдены</div>`;
|
||
return;
|
||
}
|
||
|
||
const itemsHtml = snaps.map(s => `
|
||
<div class="flex items-center justify-between p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-2 shadow-sm hover:border-indigo-300 transition">
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex items-center gap-1.5">
|
||
<span class="font-bold text-slate-800">${escapeHtml(s.snapshot_id)}</span>
|
||
${s.is_final ? '<span class="px-1.5 py-0.2 rounded text-[9px] font-bold bg-amber-50 text-amber-700 border border-amber-200">Финал Y</span>' : ''}
|
||
</div>
|
||
<div class="text-[10px] text-slate-400 mt-0.5">${escapeHtml(s.snapshot_time)} · ${s.record_count || 0} зап.</div>
|
||
</div>
|
||
<button onclick="window.sendChatAction('покажи срез ${escapeHtml(s.snapshot_id)}')" class="px-2 py-1 bg-slate-100 hover:bg-indigo-50 text-slate-600 hover:text-indigo-600 rounded-lg text-[10px] font-semibold transition" title="Открыть в чате">
|
||
Инспекция
|
||
</button>
|
||
</div>
|
||
`).join('');
|
||
|
||
container.innerHTML = `
|
||
<div class="p-2 flex flex-col gap-2">
|
||
<div class="flex items-center justify-between px-1">
|
||
<span class="text-xs font-bold text-slate-700">Всего срезов: ${snaps.length}</span>
|
||
<button onclick="SidebarManager.renderSnapshotsView(document.getElementById('sidebar-dynamic-content'))" class="text-slate-400 hover:text-indigo-600 p-1" title="Обновить">
|
||
<i class="fa-solid fa-arrows-rotate text-xs"></i>
|
||
</button>
|
||
</div>
|
||
<div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">${itemsHtml}</div>
|
||
</div>
|
||
`;
|
||
} catch (e) {
|
||
container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки срезов</div>`;
|
||
}
|
||
},
|
||
|
||
// =========================================================================
|
||
// ХАБ 3: РЕЕСТРЫ (УДАЛЕНЩИКИ + ИСКЛЮЧЕНИЯ + МЕСТ. КОМАНДИР. + ИНОЕ)
|
||
// =========================================================================
|
||
renderRegistriesView(container) {
|
||
const subTab = this.currentSubTab['REGISTRIES'] || 'REMOTE';
|
||
if (subTab === 'REMOTE') {
|
||
this.renderRemoteWorkersView(container);
|
||
} else if (subTab === 'EXCEPTIONS') {
|
||
this.renderExceptionsView(container);
|
||
} else {
|
||
this.renderManualAbsencesView(container, subTab);
|
||
}
|
||
},
|
||
|
||
async renderRemoteWorkersView(container) {
|
||
container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка удаленщиков...</div>`;
|
||
try {
|
||
const res = await fetch("/api/v1/remote-workers", { headers: AuthManager.getAuthHeaders() });
|
||
const data = res.ok ? await res.json() : { workers: [] };
|
||
const workers = data.workers || [];
|
||
|
||
const listHtml = workers.map(w => {
|
||
const dFrom = w.date_from ? w.date_from : 'сегодня';
|
||
const dTo = w.date_to ? w.date_to : 'бессрочно';
|
||
const periodLabel = (!w.date_to) ? `с ${dFrom} (бессрочно)` : `${dFrom} — ${dTo}`;
|
||
|
||
return `
|
||
<div class="flex items-center justify-between p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-2 shadow-sm hover:border-emerald-300 transition">
|
||
<div class="min-w-0 flex-1">
|
||
<div class="font-bold text-slate-800 truncate">${escapeHtml(w.fio)}</div>
|
||
<div class="text-[10px] text-slate-400 truncate">${escapeHtml(w.department || 'Все')}</div>
|
||
<div class="mt-0.5 inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">
|
||
<i class="fa-regular fa-calendar-days text-[8px]"></i>
|
||
<span>${escapeHtml(periodLabel)}</span>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-0.5 shrink-0">
|
||
<button onclick="openRemoteWorkerModal('EDIT', '${escapeHtml(w.fio)}', '${escapeHtml(w.department || 'Все')}', '${escapeHtml(w.date_from || '')}', '${escapeHtml(w.date_to || '')}')"
|
||
class="text-slate-400 hover:text-emerald-600 p-1.5 rounded-lg hover:bg-emerald-50 transition"
|
||
title="Изменить сроки удаленки">
|
||
<i class="fa-solid fa-pen-to-square text-xs"></i>
|
||
</button>
|
||
<button onclick="SidebarManager.deleteRemoteWorker('${escapeHtml(w.fio)}')"
|
||
class="text-slate-400 hover:text-rose-600 p-1.5 rounded-lg hover:bg-rose-50 transition"
|
||
title="Удалить">
|
||
<i class="fa-solid fa-trash-can text-xs"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
container.innerHTML = `
|
||
<div class="p-2 flex flex-col gap-2.5">
|
||
<div class="flex items-center justify-between px-1">
|
||
<span class="text-xs font-bold text-slate-700">В реестре: ${workers.length} чел.</span>
|
||
<button onclick="openRemoteWorkerModal('ADD')" class="px-2 py-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-[11px] font-bold shadow-sm flex items-center gap-1 transition">
|
||
<i class="fa-solid fa-plus text-[10px]"></i> Добавить
|
||
</button>
|
||
</div>
|
||
<div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">
|
||
${workers.length > 0 ? listHtml : '<div class="text-center py-8 text-xs text-slate-400">Список удаленщиков пуст</div>'}
|
||
</div>
|
||
</div>
|
||
`;
|
||
} catch (e) {
|
||
container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки реестра удаленщиков</div>`;
|
||
}
|
||
},
|
||
|
||
async deleteRemoteWorker(fio) {
|
||
if (!confirm(`Удалить сотрудника ${fio} из реестра удаленщиков?`)) return;
|
||
try {
|
||
const res = await fetch(`/api/v1/remote-workers?fio=${encodeURIComponent(fio)}`, {
|
||
method: "DELETE",
|
||
headers: AuthManager.getAuthHeaders()
|
||
});
|
||
if (res.ok) {
|
||
this.renderContent();
|
||
} else {
|
||
alert("Ошибка удаления");
|
||
}
|
||
} catch (e) {
|
||
alert("Ошибка сети");
|
||
}
|
||
},
|
||
|
||
async renderExceptionsView(container) {
|
||
container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка исключений...</div>`;
|
||
try {
|
||
const res = await fetch("/api/v1/exceptions/", { headers: AuthManager.getAuthHeaders() });
|
||
const data = res.ok ? await res.json() : {};
|
||
const categories = [
|
||
{ key: 'include_fio', title: 'Белый список (ФИО)' },
|
||
{ key: 'fio', title: 'Исключенные сотрудники (ФИО)' },
|
||
{ key: 'departments', title: 'Исключенные отделы' },
|
||
{ key: 'positions', title: 'Исключенные должности' }
|
||
];
|
||
|
||
const html = categories.map(cat => {
|
||
const items = data[cat.key] || [];
|
||
return `
|
||
<div class="bg-white border border-slate-200 rounded-xl p-3 flex flex-col gap-2 shadow-sm">
|
||
<div class="flex items-center justify-between">
|
||
<span class="font-bold text-xs text-slate-700">${cat.title} (${items.length})</span>
|
||
<button onclick="SidebarManager.addExceptionPrompt('${cat.key}')" class="text-indigo-600 hover:text-indigo-800 text-xs font-bold">
|
||
+ Добавить
|
||
</button>
|
||
</div>
|
||
<div class="flex flex-wrap gap-1">
|
||
${items.map(it => `
|
||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-slate-100 text-slate-700 border border-slate-200">
|
||
${escapeHtml(it)}
|
||
<button onclick="SidebarManager.deleteExceptionItem('${cat.key}', '${escapeHtml(it)}')" class="hover:text-rose-600 ml-0.5">×</button>
|
||
</span>
|
||
`).join('') || '<span class="text-[10px] text-slate-400">Пусто</span>'}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
container.innerHTML = `<div class="p-2 flex flex-col gap-2 max-h-[75vh] overflow-y-auto">${html}</div>`;
|
||
} catch (e) {
|
||
container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки исключений</div>`;
|
||
}
|
||
},
|
||
|
||
async addExceptionPrompt(category) {
|
||
const val = prompt(`Введите значение для категории [${category}]:`);
|
||
if (!val || !val.trim()) return;
|
||
try {
|
||
const res = await fetch("/api/v1/exceptions/", {
|
||
method: "POST",
|
||
headers: AuthManager.getAuthHeaders(),
|
||
body: JSON.stringify({ category: category, value: val.trim() })
|
||
});
|
||
if (res.ok) this.renderContent();
|
||
else alert("Ошибка добавления");
|
||
} catch (e) {
|
||
alert("Ошибка сети");
|
||
}
|
||
},
|
||
|
||
async deleteExceptionItem(category, value) {
|
||
if (!confirm(`Удалить "${value}" из ${category}?`)) return;
|
||
try {
|
||
const res = await fetch(`/api/v1/exceptions/?category=${encodeURIComponent(category)}&value=${encodeURIComponent(value)}`, {
|
||
method: "DELETE",
|
||
headers: AuthManager.getAuthHeaders()
|
||
});
|
||
if (res.ok) this.renderContent();
|
||
else alert("Ошибка удаления");
|
||
} catch (e) {
|
||
alert("Ошибка сети");
|
||
}
|
||
},
|
||
|
||
// ⭐️ Новые подвкладки: Местная командировка и Иное
|
||
async renderManualAbsencesView(container, absenceType) {
|
||
const typeLabel = absenceType === 'LOCAL_TRIP' ? 'местных командировок' : 'иных отсутствий';
|
||
container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка ${typeLabel}...</div>`;
|
||
try {
|
||
const res = await fetch(`/api/v1/manual-absences/?type=${absenceType}`);
|
||
const data = res.ok ? await res.json() : { items: [] };
|
||
const items = data.items || [];
|
||
|
||
const listHtml = items.map(it => {
|
||
const dFrom = it.date_start ? it.date_start : 'сегодня';
|
||
const dTo = it.date_end ? it.date_end : 'сегодня';
|
||
const periodLabel = (dFrom === dTo) ? `на ${dFrom}` : `${dFrom} — ${dTo}`;
|
||
const badgeText = absenceType === 'LOCAL_TRIP' ? 'Местная командировка' : escapeHtml(it.reason);
|
||
|
||
return `
|
||
<div class="flex items-center justify-between p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-2 shadow-sm hover:border-indigo-300 transition">
|
||
<div class="min-w-0 flex-1">
|
||
<div class="font-bold text-slate-800 truncate">${escapeHtml(it.fio)}</div>
|
||
<div class="text-[10px] text-slate-400 truncate">${escapeHtml(it.department || 'Все')} · ${badgeText}</div>
|
||
<div class="mt-0.5 inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-indigo-50 text-indigo-700 border border-indigo-200">
|
||
<i class="fa-regular fa-calendar-days text-[8px]"></i>
|
||
<span>${escapeHtml(periodLabel)}</span>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-0.5 shrink-0">
|
||
<button onclick="SidebarManager.deleteManualAbsenceRecord(${it.id})"
|
||
class="text-slate-400 hover:text-rose-600 p-1.5 rounded-lg hover:bg-rose-50 transition"
|
||
title="Удалить">
|
||
<i class="fa-solid fa-trash-can text-xs"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
container.innerHTML = `
|
||
<div class="p-2 flex flex-col gap-2.5">
|
||
<div class="flex items-center justify-between px-1">
|
||
<span class="text-xs font-bold text-slate-700">В реестре: ${items.length} чел.</span>
|
||
<button onclick="openManualAbsenceModal('${absenceType}')" class="px-2 py-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-[11px] font-bold shadow-sm flex items-center gap-1 transition">
|
||
<i class="fa-solid fa-plus text-[10px]"></i> Добавить
|
||
</button>
|
||
</div>
|
||
<div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">
|
||
${items.length > 0 ? listHtml : '<div class="text-center py-8 text-xs text-slate-400">Список пуст</div>'}
|
||
</div>
|
||
</div>
|
||
`;
|
||
} catch (e) {
|
||
container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки реестра</div>`;
|
||
}
|
||
},
|
||
|
||
async deleteManualAbsenceRecord(id) {
|
||
if (!confirm("Удалить эту запись из реестра?")) return;
|
||
try {
|
||
const res = await fetch(`/api/v1/manual-absences/${id}`, { method: "DELETE" });
|
||
if (res.ok) {
|
||
this.renderContent();
|
||
} else {
|
||
alert("Ошибка удаления");
|
||
}
|
||
} catch (e) {
|
||
alert("Ошибка сети");
|
||
}
|
||
},
|
||
|
||
// =========================================================================
|
||
// ХАБ 4: СИСТЕМНЫЙ ПРОМПТ И БАЗА ЗНАНИЙ
|
||
// =========================================================================
|
||
renderPromptView(container) {
|
||
container.innerHTML = `
|
||
<div class="p-3 flex flex-col gap-3">
|
||
<div class="text-[11px] text-slate-600 leading-relaxed bg-white border border-slate-200 rounded-xl p-3 shadow-sm flex flex-col gap-1.5">
|
||
<span class="font-bold text-slate-800 flex items-center gap-1.5">
|
||
<i class="fa-solid fa-sliders text-indigo-600"></i> Инструкции и регламенты ИИ
|
||
</span>
|
||
<span>Управление системными директивами, базой знаний и правилами арбитража кадровых аномалий СКУД и 1С.</span>
|
||
</div>
|
||
|
||
<div class="flex flex-col gap-2">
|
||
<button onclick="window.sendChatAction('покажи системный промпт')"
|
||
class="w-full py-2 px-3 bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
|
||
<i class="fa-solid fa-terminal text-xs"></i>
|
||
<span>Показать системный промпт</span>
|
||
</button>
|
||
|
||
<button onclick="window.sendChatAction('покажи правила компании')"
|
||
class="w-full py-2 px-3 bg-white hover:bg-slate-50 active:bg-slate-100 text-slate-700 border border-slate-300 rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
|
||
<i class="fa-solid fa-book-bookmark text-emerald-600 text-xs"></i>
|
||
<span>База знаний и правила компании</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
},
|
||
|
||
// =========================================================================
|
||
// ХАБ 5: УПРАВЛЕНИЕ КОНТЕКСТОМ СЕССИИ
|
||
// =========================================================================
|
||
renderContextView(container) {
|
||
container.innerHTML = `
|
||
<div class="p-3 flex flex-col gap-3">
|
||
<div class="text-[11px] text-slate-600 leading-relaxed bg-white border border-slate-200 rounded-xl p-3 shadow-sm flex flex-col gap-2">
|
||
<span class="font-bold text-slate-800 flex items-center gap-1.5">
|
||
<i class="fa-solid fa-brain text-indigo-600"></i> Управление памятью чата
|
||
</span>
|
||
|
||
<div class="flex flex-col gap-1.5 pt-1 border-t border-slate-100">
|
||
<div class="flex items-start gap-1.5">
|
||
<span class="w-2 h-2 rounded-full bg-amber-500 mt-1 shrink-0"></span>
|
||
<div>
|
||
<span class="font-bold text-slate-700">Мягкая очистка:</span>
|
||
<span class="text-slate-500"> удаляет только служебные транзакции (карточки срезов, временные превью промпта, промежуточные подтверждения). Смысловой диалог пользователя сохраняется.</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-start gap-1.5">
|
||
<span class="w-2 h-2 rounded-full bg-rose-500 mt-1 shrink-0"></span>
|
||
<div>
|
||
<span class="font-bold text-slate-700">Полный сброс:</span>
|
||
<span class="text-slate-500"> полностью стирает контекст активной сессии из базы данных и очищает окно чата. Используется при переходе к новой дате или новой теме анализа.</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex flex-col gap-2">
|
||
<button onclick="window.sendChatAction('очисти контекст')"
|
||
class="w-full py-2 px-3 bg-amber-500 hover:bg-amber-600 active:bg-amber-700 text-white rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
|
||
<i class="fa-solid fa-broom text-xs"></i>
|
||
<span>Мягкая очистка контекста</span>
|
||
</button>
|
||
|
||
<button onclick="SidebarManager.handleFullSessionReset()"
|
||
class="w-full py-2 px-3 bg-rose-600 hover:bg-rose-700 active:bg-rose-800 text-white rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
|
||
<i class="fa-solid fa-trash-arrow-up text-xs"></i>
|
||
<span>Полный сброс сессии</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
},
|
||
|
||
async handleFullSessionReset() {
|
||
if (!confirm("Вы действительно хотите полностью очистить историю диалога и сбросить сессию чата?")) {
|
||
return;
|
||
}
|
||
|
||
const chatContainer = document.getElementById("chat-messages-container");
|
||
if (chatContainer) {
|
||
chatContainer.innerHTML = `
|
||
<div class="flex gap-3 max-w-4xl mx-auto w-full">
|
||
<div class="w-7 h-7 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
|
||
<i class="fa-solid fa-robot text-xs"></i>
|
||
</div>
|
||
<div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm">
|
||
<div class="text-[10px] font-bold text-indigo-600 uppercase tracking-wider mb-1">ИИ-ассистент SCUD Orion AI</div>
|
||
<div class="text-xs text-slate-700 leading-relaxed">
|
||
Сессия чата очищена. Память ассистента сброшена. Задайте новый вопрос или команду.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
try {
|
||
await fetch("/api/v1/chat", {
|
||
method: "POST",
|
||
headers: AuthManager.getAuthHeaders(),
|
||
body: JSON.stringify({
|
||
message: "сбрось сессию полностью",
|
||
session_id: "web_session_main"
|
||
})
|
||
});
|
||
} catch (e) {
|
||
console.error("Ошибка запроса сброса сессии:", e);
|
||
}
|
||
}
|
||
};
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
if (window.AuthManager && AuthManager.isAuthenticated()) {
|
||
SidebarManager.init();
|
||
}
|
||
});
|
||
```
|
||
|
||
## File: `./modules/web_api/static/js/tasks.js`
|
||
```js
|
||
/**
|
||
* ===============================================================================
|
||
* FILE: modules/web_api/static/js/tasks.js
|
||
* ROLE: Управление персональными задачами оператора (CRUD, фильтры, рендер).
|
||
* ===============================================================================
|
||
*/
|
||
|
||
let currentTasksFilter = 'ALL';
|
||
let tasksCache = [];
|
||
|
||
function getTasksContainer() {
|
||
return document.getElementById("tasks-list-container") ||
|
||
document.getElementById("sidebar-dynamic-content") ||
|
||
document.getElementById("tasks-list");
|
||
}
|
||
|
||
async function loadTasks() {
|
||
const container = getTasksContainer();
|
||
if (!container) return;
|
||
|
||
container.innerHTML = `
|
||
<div class="text-center py-8 text-xs text-slate-400">
|
||
<i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка задач...
|
||
</div>
|
||
`;
|
||
|
||
try {
|
||
const res = await fetch("/api/v1/tasks", {
|
||
headers: AuthManager.getAuthHeaders()
|
||
});
|
||
|
||
if (!res.ok) {
|
||
if (res.status === 401) {
|
||
showAuthModal();
|
||
return;
|
||
}
|
||
throw new Error(`Ошибка сервера (${res.status})`);
|
||
}
|
||
|
||
const data = await res.json();
|
||
tasksCache = Array.isArray(data) ? data : (data.tasks || []);
|
||
renderTasksUI();
|
||
} catch (err) {
|
||
console.error("[Tasks] Ошибка загрузки:", err);
|
||
container.innerHTML = `
|
||
<div class="p-4 text-xs text-rose-500 text-center flex flex-col items-center gap-2">
|
||
<i class="fa-solid fa-triangle-exclamation text-base"></i>
|
||
<span>Не удалось загрузить задачи</span>
|
||
<button onclick="loadTasks()" class="px-2.5 py-1 bg-slate-200 hover:bg-slate-300 text-slate-700 rounded text-[11px] font-semibold transition">Повторить</button>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
function setTaskFilter(filter) {
|
||
currentTasksFilter = filter;
|
||
renderTasksUI();
|
||
}
|
||
|
||
function renderTasksUI() {
|
||
const container = getTasksContainer();
|
||
if (!container) return;
|
||
|
||
let filtered = tasksCache;
|
||
if (currentTasksFilter === 'IN_PROGRESS') {
|
||
filtered = tasksCache.filter(t => t.status === 'IN_PROGRESS');
|
||
} else if (currentTasksFilter === 'BACKLOG') {
|
||
filtered = tasksCache.filter(t => t.status === 'BACKLOG' || t.status === 'PLANNED');
|
||
} else if (currentTasksFilter === 'COMPLETED') {
|
||
filtered = tasksCache.filter(t => t.status === 'COMPLETED' || t.status === 'DONE');
|
||
}
|
||
|
||
const filtersHtml = `
|
||
<div class="flex items-center gap-1 p-1 bg-slate-200/70 rounded-lg text-[11px] font-semibold mb-2">
|
||
<button onclick="setTaskFilter('ALL')" class="flex-1 py-1 rounded text-center transition ${currentTasksFilter === 'ALL' ? 'bg-white text-indigo-700 shadow-sm' : 'text-slate-600 hover:text-slate-900'}">Все</button>
|
||
<button onclick="setTaskFilter('IN_PROGRESS')" class="flex-1 py-1 rounded text-center transition ${currentTasksFilter === 'IN_PROGRESS' ? 'bg-white text-indigo-700 shadow-sm' : 'text-slate-600 hover:text-slate-900'}">В работе</button>
|
||
<button onclick="setTaskFilter('BACKLOG')" class="flex-1 py-1 rounded text-center transition ${currentTasksFilter === 'BACKLOG' ? 'bg-white text-indigo-700 shadow-sm' : 'text-slate-600 hover:text-slate-900'}">Планы</button>
|
||
<button onclick="setTaskFilter('COMPLETED')" class="flex-1 py-1 rounded text-center transition ${currentTasksFilter === 'COMPLETED' ? 'bg-white text-indigo-700 shadow-sm' : 'text-slate-600 hover:text-slate-900'}">Готово</button>
|
||
</div>
|
||
`;
|
||
|
||
const addBtnHtml = `
|
||
<div class="flex items-center justify-between px-1 mb-1.5">
|
||
<span class="text-xs font-bold text-slate-700">Задачи: ${filtered.length}</span>
|
||
<button onclick="openCreateTaskModal()" class="px-2 py-1 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-[11px] font-bold shadow-sm flex items-center gap-1 transition">
|
||
<i class="fa-solid fa-plus text-[10px]"></i> Новая
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
if (filtered.length === 0) {
|
||
container.innerHTML = `
|
||
${filtersHtml}
|
||
${addBtnHtml}
|
||
<div class="text-center py-8 text-xs text-slate-400 bg-white border border-slate-200 rounded-xl p-4">
|
||
Нет задач в выбранной категории
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
const itemsHtml = filtered.map(t => {
|
||
const isDone = t.status === 'COMPLETED' || t.status === 'DONE';
|
||
const priorityColors = {
|
||
'HIGH': 'bg-rose-50 text-rose-700 border-rose-200',
|
||
'MEDIUM': 'bg-amber-50 text-amber-700 border-amber-200',
|
||
'LOW': 'bg-slate-50 text-slate-600 border-slate-200'
|
||
};
|
||
const pClass = priorityColors[t.priority] || priorityColors['MEDIUM'];
|
||
|
||
return `
|
||
<div class="flex flex-col p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-1.5 shadow-sm hover:border-indigo-300 transition">
|
||
<div class="flex items-start justify-between gap-2">
|
||
<div class="flex items-center gap-1.5 min-w-0">
|
||
<button onclick="toggleTaskStatus(${t.id}, '${t.status}')" class="text-slate-400 hover:text-indigo-600 transition shrink-0">
|
||
<i class="fa-${isDone ? 'solid fa-circle-check text-emerald-500' : 'regular fa-circle'} text-sm"></i>
|
||
</button>
|
||
<span class="font-bold text-slate-800 ${isDone ? 'line-through text-slate-400' : ''} truncate">${escapeHtml(t.title)}</span>
|
||
</div>
|
||
<span class="px-1.5 py-0.5 rounded text-[9px] font-semibold border ${pClass} shrink-0">${t.priority || 'NORMAL'}</span>
|
||
</div>
|
||
|
||
<div class="flex items-center justify-between text-[10px] text-slate-400 pt-1 border-t border-slate-100">
|
||
<span>${t.task_id || ('#' + t.id)} · ${escapeHtml(t.module || 'general')}</span>
|
||
<div class="flex items-center gap-1">
|
||
<button onclick="deleteTaskItem(${t.id})" class="text-slate-400 hover:text-rose-600 p-0.5 transition" title="Удалить">
|
||
<i class="fa-solid fa-trash-can text-[11px]"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
container.innerHTML = `
|
||
${filtersHtml}
|
||
${addBtnHtml}
|
||
<div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">
|
||
${itemsHtml}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function toggleTaskStatus(id, currentStatus) {
|
||
const newStatus = (currentStatus === 'COMPLETED' || currentStatus === 'DONE') ? 'IN_PROGRESS' : 'COMPLETED';
|
||
try {
|
||
const res = await fetch(`/api/v1/tasks/${id}`, {
|
||
method: "PATCH",
|
||
headers: AuthManager.getAuthHeaders(),
|
||
body: JSON.stringify({ status: newStatus })
|
||
});
|
||
if (res.ok) {
|
||
loadTasks();
|
||
}
|
||
} catch (e) {
|
||
console.error("Ошибка смены статуса задачи:", e);
|
||
}
|
||
}
|
||
|
||
async function deleteTaskItem(id) {
|
||
if (!confirm("Удалить эту задачу?")) return;
|
||
try {
|
||
const res = await fetch(`/api/v1/tasks/${id}`, {
|
||
method: "DELETE",
|
||
headers: AuthManager.getAuthHeaders()
|
||
});
|
||
if (res.ok) {
|
||
loadTasks();
|
||
}
|
||
} catch (e) {
|
||
alert("Ошибка сети при удалении");
|
||
}
|
||
}
|
||
|
||
async function openCreateTaskModal() {
|
||
const title = prompt("Введите описание новой задачи:");
|
||
if (!title || !title.trim()) return;
|
||
|
||
try {
|
||
const res = await fetch("/api/v1/tasks", {
|
||
method: "POST",
|
||
headers: AuthManager.getAuthHeaders(),
|
||
body: JSON.stringify({
|
||
title: title.trim(),
|
||
priority: "MEDIUM",
|
||
status: "IN_PROGRESS"
|
||
})
|
||
});
|
||
if (res.ok) {
|
||
loadTasks();
|
||
} else {
|
||
alert("Не удалось создать задачу");
|
||
}
|
||
} catch (e) {
|
||
alert("Ошибка сети");
|
||
}
|
||
}
|
||
|
||
window.loadTasks = loadTasks;
|
||
window.setTaskFilter = setTaskFilter;
|
||
```
|
||
|
||
## File: `./modules/web_api/static/js/manual_absences.js`
|
||
```js
|
||
/**
|
||
* ===============================================================================
|
||
* FILE: modules/web_api/static/js/manual_absences.js
|
||
* ROLE: Модальные окна "Мест. командир.", "Иное", живой автокомплит ФИО из 1С:ЗУП
|
||
* и мгновенная синхронизация с боковой панелью SidebarManager.
|
||
* ===============================================================================
|
||
*/
|
||
|
||
let activeAbsenceType = 'LOCAL_TRIP'; // 'LOCAL_TRIP' или 'OTHER'
|
||
let reasonsCache = [];
|
||
|
||
async function loadAbsenceReasons() {
|
||
try {
|
||
const res = await fetch('/api/v1/manual-absences/reasons');
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
reasonsCache = data.reasons || [];
|
||
}
|
||
} catch (e) {
|
||
console.error('Ошибка загрузки причин:', e);
|
||
}
|
||
}
|
||
|
||
function openManualAbsenceModal(type) {
|
||
activeAbsenceType = type;
|
||
const isTrip = type === 'LOCAL_TRIP';
|
||
const titleEl = document.getElementById('manual-absence-modal-title');
|
||
const reasonBlock = document.getElementById('manual-absence-reason-block');
|
||
const reasonSelect = document.getElementById('manual-absence-reason-select');
|
||
|
||
if (titleEl) {
|
||
titleEl.innerHTML = isTrip
|
||
? '<i class="fa-solid fa-location-dot text-indigo-600 mr-2"></i>Местная командировка'
|
||
: '<i class="fa-solid fa-clipboard-list text-purple-600 mr-2"></i>Иные причины отсутствия';
|
||
}
|
||
|
||
if (reasonBlock && reasonSelect) {
|
||
if (isTrip) {
|
||
reasonBlock.classList.add('hidden');
|
||
} else {
|
||
reasonBlock.classList.remove('hidden');
|
||
reasonSelect.innerHTML = reasonsCache.map(r => `<option value="${r}">${r}</option>`).join('');
|
||
}
|
||
}
|
||
|
||
// Сброс полей ввода
|
||
const fioInput = document.getElementById('manual-absence-fio-input');
|
||
const deptInput = document.getElementById('manual-absence-dept');
|
||
const posInput = document.getElementById('manual-absence-pos');
|
||
const startDateInput = document.getElementById('manual-absence-start-date');
|
||
const endDateInput = document.getElementById('manual-absence-end-date');
|
||
const suggestionsBox = document.getElementById('manual-absence-suggestions');
|
||
|
||
if (fioInput) fioInput.value = '';
|
||
if (deptInput) deptInput.value = '';
|
||
if (posInput) posInput.value = '';
|
||
if (startDateInput) startDateInput.value = '';
|
||
if (suggestionsBox) {
|
||
suggestionsBox.classList.add('hidden');
|
||
suggestionsBox.innerHTML = '';
|
||
}
|
||
|
||
// Окончание по умолчанию — сегодняшний день
|
||
if (endDateInput) {
|
||
const today = new Date().toISOString().split('T')[0];
|
||
endDateInput.value = today;
|
||
}
|
||
|
||
loadManualAbsencesTable();
|
||
const modal = document.getElementById('manual-absence-modal');
|
||
if (modal) modal.classList.remove('hidden');
|
||
}
|
||
|
||
function closeManualAbsenceModal() {
|
||
const modal = document.getElementById('manual-absence-modal');
|
||
if (modal) modal.classList.add('hidden');
|
||
}
|
||
|
||
// Живой автокомплит ФИО из базы zup_staff
|
||
let searchTimeout = null;
|
||
function setupStaffAutocomplete(inputEl, suggestionsBoxId) {
|
||
const box = document.getElementById(suggestionsBoxId);
|
||
if (!inputEl || !box) return;
|
||
|
||
inputEl.addEventListener('input', function() {
|
||
const val = this.value.trim();
|
||
clearTimeout(searchTimeout);
|
||
if (val.length < 2) {
|
||
box.classList.add('hidden');
|
||
box.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
searchTimeout = setTimeout(async () => {
|
||
try {
|
||
const res = await fetch(`/api/v1/manual-absences/staff-autocomplete?q=${encodeURIComponent(val)}`);
|
||
if (!res.ok) return;
|
||
const items = await res.json();
|
||
if (items.length === 0) {
|
||
box.classList.add('hidden');
|
||
return;
|
||
}
|
||
|
||
box.innerHTML = items.map(it => `
|
||
<div class="p-2 hover:bg-indigo-50 cursor-pointer border-b border-slate-100 flex flex-col text-xs"
|
||
onclick="selectStaffSuggestion('${escapeHtml(it.fio)}', '${escapeHtml(it.department)}', '${escapeHtml(it.position)}')">
|
||
<span class="font-bold text-slate-800">${escapeHtml(it.fio)}</span>
|
||
<span class="text-[10px] text-slate-500">${escapeHtml(it.department)} · ${escapeHtml(it.position)}</span>
|
||
</div>
|
||
`).join('');
|
||
box.classList.remove('hidden');
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
}, 200);
|
||
});
|
||
}
|
||
|
||
function selectStaffSuggestion(fio, dept, pos) {
|
||
const fioInput = document.getElementById('manual-absence-fio-input');
|
||
const deptInput = document.getElementById('manual-absence-dept');
|
||
const posInput = document.getElementById('manual-absence-pos');
|
||
const box = document.getElementById('manual-absence-suggestions');
|
||
|
||
if (fioInput) fioInput.value = fio;
|
||
if (deptInput) deptInput.value = dept;
|
||
if (posInput) posInput.value = pos;
|
||
if (box) box.classList.add('hidden');
|
||
}
|
||
|
||
async function submitManualAbsence() {
|
||
const fioInput = document.getElementById('manual-absence-fio-input');
|
||
const fio = fioInput ? fioInput.value.trim() : '';
|
||
if (!fio) {
|
||
alert('Укажите ФИО сотрудника');
|
||
return;
|
||
}
|
||
|
||
const deptVal = document.getElementById('manual-absence-dept')?.value.trim() || '';
|
||
const posVal = document.getElementById('manual-absence-pos')?.value.trim() || '';
|
||
const startDateVal = document.getElementById('manual-absence-start-date')?.value || null;
|
||
const endDateVal = document.getElementById('manual-absence-end-date')?.value || null;
|
||
const reasonSelect = document.getElementById('manual-absence-reason-select');
|
||
|
||
const payload = {
|
||
absence_type: activeAbsenceType,
|
||
fio: fio,
|
||
department: deptVal,
|
||
position: posVal,
|
||
date_start: startDateVal,
|
||
date_end: endDateVal,
|
||
reason: activeAbsenceType === 'LOCAL_TRIP' ? 'Местная командировка' : (reasonSelect ? reasonSelect.value : 'Иное')
|
||
};
|
||
|
||
try {
|
||
const res = await fetch('/api/v1/manual-absences/', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
if (res.ok) {
|
||
if (fioInput) fioInput.value = '';
|
||
loadManualAbsencesTable();
|
||
// Обновляем список карточек в боковой панели и закрываем окно
|
||
if (window.SidebarManager && typeof SidebarManager.renderContent === 'function') {
|
||
SidebarManager.renderContent();
|
||
}
|
||
closeManualAbsenceModal();
|
||
} else {
|
||
alert('Ошибка добавления записи');
|
||
}
|
||
} catch (e) {
|
||
alert('Сетевая ошибка при добавлении');
|
||
}
|
||
}
|
||
|
||
async function loadManualAbsencesTable() {
|
||
const tableContainer = document.getElementById('manual-absences-table-body');
|
||
if (!tableContainer) return;
|
||
|
||
try {
|
||
const res = await fetch(`/api/v1/manual-absences/?type=${activeAbsenceType}`);
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
const items = data.items || [];
|
||
|
||
if (items.length === 0) {
|
||
tableContainer.innerHTML = '<tr><td colspan="5" class="text-center p-4 text-xs text-slate-400">Нет активных записей</td></tr>';
|
||
return;
|
||
}
|
||
|
||
tableContainer.innerHTML = items.map(it => `
|
||
<tr class="border-b border-slate-100 text-xs hover:bg-slate-50">
|
||
<td class="p-2 font-bold text-slate-800">${escapeHtml(it.fio)}</td>
|
||
<td class="p-2 text-slate-500">${escapeHtml(it.department || '—')}</td>
|
||
<td class="p-2 text-slate-600">${escapeHtml(it.reason)}</td>
|
||
<td class="p-2 text-center text-slate-500 font-mono text-[11px]">${it.date_start || '—'} / ${it.date_end || '—'}</td>
|
||
<td class="p-2 text-center">
|
||
<button onclick="deleteManualAbsenceRecord(${it.id})" class="text-slate-400 hover:text-rose-600 transition p-1" title="Удалить">
|
||
<i class="fa-solid fa-trash-can"></i>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
}
|
||
|
||
async function deleteManualAbsenceRecord(id) {
|
||
if (!confirm('Удалить эту запись?')) return;
|
||
try {
|
||
const res = await fetch(`/api/v1/manual-absences/${id}`, { method: 'DELETE' });
|
||
if (res.ok) {
|
||
loadManualAbsencesTable();
|
||
if (window.SidebarManager && typeof SidebarManager.renderContent === 'function') {
|
||
SidebarManager.renderContent();
|
||
}
|
||
}
|
||
} catch (e) {
|
||
alert('Ошибка при удалении');
|
||
}
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
loadAbsenceReasons();
|
||
setupStaffAutocomplete(
|
||
document.getElementById('manual-absence-fio-input'),
|
||
'manual-absence-suggestions'
|
||
);
|
||
});
|
||
```
|
||
|
||
## File: `./modules/web_api/static/js/chat/core.js`
|
||
```js
|
||
/**
|
||
* ===============================================================================
|
||
* FILE: modules/web_api/static/js/chat/core.js
|
||
* ROLE: Ядро чата: полноэкранный Drag-and-Drop оверлей, авто-высота инпута (24px),
|
||
* надежный расчет скролла вопроса к верху окна, крупный шрифт text-sm.
|
||
* ===============================================================================
|
||
*/
|
||
|
||
let currentAttachedFile = null;
|
||
|
||
const CHAT_INPUT_STORAGE_KEY = "scud_chat_input_history";
|
||
let chatInputHistory = JSON.parse(localStorage.getItem(CHAT_INPUT_STORAGE_KEY) || "[]");
|
||
let chatHistoryIndex = -1;
|
||
let temporaryCurrentInput = "";
|
||
|
||
function saveCommandToHistory(commandText) {
|
||
if (!commandText || !commandText.trim()) return;
|
||
const cleanCmd = commandText.trim();
|
||
if (cleanCmd.startsWith("action:save_draft_")) return;
|
||
|
||
chatInputHistory = chatInputHistory.filter(item => item !== cleanCmd);
|
||
chatInputHistory.push(cleanCmd);
|
||
if (chatInputHistory.length > 50) chatInputHistory.shift();
|
||
localStorage.setItem(CHAT_INPUT_STORAGE_KEY, JSON.stringify(chatInputHistory));
|
||
chatHistoryIndex = -1;
|
||
}
|
||
|
||
// ⭐️ ЕДИНАЯ ФУНКЦИЯ СКРОЛЛА: выравнивание вопроса к верхней границе
|
||
function scrollToUserMessageTop() {
|
||
const container = document.getElementById("chat-messages-container");
|
||
if (!container) return;
|
||
|
||
const userBubbles = container.querySelectorAll(".user-chat-bubble");
|
||
const targetEl = userBubbles[userBubbles.length - 1];
|
||
if (!targetEl) return;
|
||
|
||
requestAnimationFrame(() => {
|
||
const targetScroll = targetEl.offsetTop - container.offsetTop - 12;
|
||
|
||
container.scrollTo({
|
||
top: Math.max(0, targetScroll),
|
||
behavior: 'smooth'
|
||
});
|
||
});
|
||
}
|
||
|
||
function updateInputHeightAndFade(textarea) {
|
||
if (!textarea) return;
|
||
|
||
if (!textarea.value || textarea.value.trim() === '') {
|
||
textarea.style.height = '24px';
|
||
textarea.style.overflowY = 'hidden';
|
||
textarea.style.maskImage = 'none';
|
||
textarea.style.webkitMaskImage = 'none';
|
||
return;
|
||
}
|
||
|
||
textarea.style.height = 'auto';
|
||
const minHeight = 24;
|
||
const maxHeight = 120;
|
||
const currentScrollHeight = textarea.scrollHeight;
|
||
|
||
if (currentScrollHeight <= minHeight + 2) {
|
||
textarea.style.height = minHeight + 'px';
|
||
textarea.style.overflowY = 'hidden';
|
||
textarea.style.maskImage = 'none';
|
||
textarea.style.webkitMaskImage = 'none';
|
||
} else if (currentScrollHeight > maxHeight) {
|
||
textarea.style.height = maxHeight + 'px';
|
||
textarea.style.overflowY = 'auto';
|
||
textarea.style.maskImage = 'linear-gradient(to bottom, transparent 0%, black 14px, black 100%)';
|
||
textarea.style.webkitMaskImage = 'linear-gradient(to bottom, transparent 0%, black 14px, black 100%)';
|
||
} else {
|
||
textarea.style.height = currentScrollHeight + 'px';
|
||
textarea.style.overflowY = 'hidden';
|
||
textarea.style.maskImage = 'none';
|
||
textarea.style.webkitMaskImage = 'none';
|
||
}
|
||
}
|
||
|
||
function appendUserMessage(text, filename = null) {
|
||
const container = document.getElementById("chat-messages-container");
|
||
if (!container) return;
|
||
|
||
const msgId = 'user-msg-' + Date.now();
|
||
let fileBadge = '';
|
||
if (filename) {
|
||
fileBadge = `
|
||
<div class="inline-flex items-center gap-1.5 px-2.5 py-1 mb-2 bg-indigo-700/80 rounded-lg text-xs font-semibold text-white shadow-xs">
|
||
<i class="fa-solid fa-paperclip text-xs"></i>
|
||
<span class="truncate max-w-xs">${escapeHtml(filename)}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
const msgHtml = `
|
||
<div id="${msgId}" class="user-chat-bubble relative flex gap-3 max-w-4xl mx-auto w-full justify-end pt-3 scroll-mt-4">
|
||
<div class="flex-1 max-w-2xl bg-indigo-600 text-white rounded-2xl rounded-tr-none p-4 shadow-sm">
|
||
${fileBadge}
|
||
<div class="text-sm leading-relaxed whitespace-pre-wrap">${escapeHtml(text)}</div>
|
||
</div>
|
||
<div class="w-8 h-8 rounded-lg bg-slate-200 text-slate-600 flex items-center justify-center shrink-0 shadow-sm mt-0.5 font-bold text-sm">
|
||
<i class="fa-solid fa-user"></i>
|
||
</div>
|
||
</div>
|
||
`;
|
||
container.insertAdjacentHTML("beforeend", msgHtml);
|
||
}
|
||
|
||
function appendAssistantLoading() {
|
||
const container = document.getElementById("chat-messages-container");
|
||
if (!container) return null;
|
||
|
||
const loadingId = 'loading-' + Date.now();
|
||
const html = `
|
||
<div id="${loadingId}" class="flex gap-3 max-w-4xl mx-auto w-full pt-1">
|
||
<div class="w-8 h-8 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
|
||
<i class="fa-solid fa-robot text-sm"></i>
|
||
</div>
|
||
<div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm">
|
||
<div class="text-sm text-slate-500 flex items-center gap-2">
|
||
<i class="fa-solid fa-spinner fa-spin text-indigo-600"></i>
|
||
<span>ИИ обрабатывает запрос...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
container.insertAdjacentHTML("beforeend", html);
|
||
return loadingId;
|
||
}
|
||
|
||
function appendAssistantMessage(text, buttons = [], actionPayload = null) {
|
||
const container = document.getElementById("chat-messages-container");
|
||
if (!container) return;
|
||
|
||
let payloadHtml = '';
|
||
let isHtmlBody = false;
|
||
|
||
if (actionPayload) {
|
||
if (actionPayload.type === 'SNAPSHOTS_CARD' && typeof renderSnapshotsCard === 'function') {
|
||
payloadHtml = renderSnapshotsCard(actionPayload.data);
|
||
} else if (actionPayload.type === 'TASK_INTERACTIVE_CARD' && typeof renderInteractiveTaskCard === 'function') {
|
||
payloadHtml = renderInteractiveTaskCard(actionPayload.tasks);
|
||
} else if (actionPayload.type === 'FILE_DOWNLOAD_CARD') {
|
||
const dlUrl = actionPayload.download_url || '#';
|
||
const fName = actionPayload.filename || 'ROADMAP.md';
|
||
const count = actionPayload.tasks_count || '';
|
||
payloadHtml = `
|
||
<div class="mt-3 p-3.5 bg-indigo-50/80 border border-indigo-200 rounded-xl flex items-center justify-between gap-3 shadow-sm">
|
||
<div class="flex items-center gap-3 min-w-0">
|
||
<div class="w-9 h-9 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm">
|
||
<i class="fa-solid fa-file-lines text-base"></i>
|
||
</div>
|
||
<div class="min-w-0">
|
||
<div class="text-sm font-bold text-slate-800 truncate">${escapeHtml(fName)}</div>
|
||
<div class="text-xs text-slate-500">Задач выгружено: ${count} шт. · Markdown</div>
|
||
</div>
|
||
</div>
|
||
<a href="${dlUrl}" download="${escapeHtml(fName)}" target="_blank"
|
||
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center gap-1.5 shrink-0">
|
||
<i class="fa-solid fa-download text-xs"></i>
|
||
<span>Скачать файл</span>
|
||
</a>
|
||
</div>
|
||
`;
|
||
} else if (actionPayload.type === 'SNAPSHOT_INSPECT_CARD') {
|
||
const records = actionPayload.records || [];
|
||
const inspectTableId = 'inspect-table-' + Date.now();
|
||
const searchInputId = 'inspect-search-' + Date.now();
|
||
|
||
const rowsHtml = records.map((r, idx) => `
|
||
<tr class="inspect-row border-b border-slate-100 text-xs ${r.is_present ? 'bg-white' : 'bg-slate-50/60'} hover:bg-indigo-50/40"
|
||
data-fio="${escapeHtml(r.fio).toLowerCase()}" data-dept="${escapeHtml(r.department).toLowerCase()}">
|
||
<td class="p-2.5 text-slate-400 text-center font-mono w-10">${idx + 1}</td>
|
||
<td class="p-2.5 font-medium text-slate-800">${escapeHtml(r.fio)}</td>
|
||
<td class="p-2.5 text-slate-500 text-center">${escapeHtml(r.department)}</td>
|
||
<td class="p-2.5 text-center ${r.time_in !== 'Нет входа' ? 'font-bold text-emerald-700' : 'text-slate-400'}">${escapeHtml(r.time_in)}</td>
|
||
<td class="p-2.5 text-center text-slate-500">${escapeHtml(r.first_activity)}</td>
|
||
<td class="p-2.5 text-center ${r.time_out !== 'Нет выхода' ? 'font-bold text-slate-800' : 'text-slate-400'}">${escapeHtml(r.time_out)}</td>
|
||
<td class="p-2.5 text-center font-mono text-slate-600">${escapeHtml(r.in_building)}</td>
|
||
<td class="p-2.5 text-center font-bold">${r.is_present ? '<span class="text-emerald-600">✓ Да</span>' : '<span class="text-slate-400">Нет</span>'}</td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
payloadHtml = `
|
||
<div class="mt-3 bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm flex flex-col">
|
||
<div class="px-4 py-3 bg-slate-100/90 border-b border-slate-200 flex items-center justify-between gap-2 flex-wrap">
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-sm font-bold text-slate-800">Срез #${escapeHtml(actionPayload.snapshot_id)}</span>
|
||
<span class="text-xs text-slate-500">· Всего: ${records.length} чел. (Присутствуют: ${actionPayload.present_count || 0})</span>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<input type="text" id="${searchInputId}" placeholder="Поиск в срезе (ФИО / отдел)..."
|
||
oninput="window.filterInspectTable('${inspectTableId}', this.value)"
|
||
class="text-xs px-3 py-1.5 bg-white border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 w-60" />
|
||
<button onclick="window.sendChatAction('покажи срезы')" class="text-xs text-indigo-600 hover:underline font-semibold">Все срезы</button>
|
||
</div>
|
||
</div>
|
||
<div class="max-h-96 overflow-y-auto">
|
||
<table id="${inspectTableId}" class="w-full text-left border-collapse">
|
||
<thead class="bg-slate-50 text-[11px] uppercase text-slate-500 sticky top-0 border-b border-slate-200 shadow-xs">
|
||
<tr>
|
||
<th class="p-2.5 text-center w-10">№</th>
|
||
<th class="p-2.5">Сотрудник</th>
|
||
<th class="p-2.5 text-center">Отдел</th>
|
||
<th class="p-2.5 text-center">Вход</th>
|
||
<th class="p-2.5 text-center">Активность</th>
|
||
<th class="p-2.5 text-center">Выход</th>
|
||
<th class="p-2.5 text-center">В здании</th>
|
||
<th class="p-2.5 text-center">Статус</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>${rowsHtml}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else if (actionPayload.type === 'PROMPT_EDITOR') {
|
||
const draftText = actionPayload.raw_draft || actionPayload.baseline_prompt || '';
|
||
const editorId = 'prompt-editor-' + Date.now();
|
||
payloadHtml = `
|
||
<div class="mt-3 p-3.5 bg-slate-50 border border-slate-300 rounded-xl flex flex-col gap-2 shadow-inner">
|
||
<div class="flex items-center justify-between text-xs font-bold text-slate-700">
|
||
<span><i class="fa-solid fa-pen-to-square text-indigo-600 mr-1"></i> Инлайн-редактор системного промпта:</span>
|
||
<span class="text-[11px] text-slate-400 font-normal">Прямое редактирование текста</span>
|
||
</div>
|
||
<textarea id="${editorId}" rows="14"
|
||
class="w-full text-xs font-mono p-3 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-white leading-relaxed resize-y">${escapeHtml(draftText)}</textarea>
|
||
<div class="flex items-center justify-end gap-2 pt-1">
|
||
<button type="button" onclick="window.sendChatAction('отмена')" class="px-3.5 py-1.5 text-xs text-slate-600 hover:bg-slate-200 rounded-lg transition font-medium">Отменить</button>
|
||
<button type="button" onclick="window.submitPromptDraftToDiff('${editorId}')" class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow transition flex items-center gap-1.5">
|
||
<i class="fa-solid fa-eye text-xs"></i>
|
||
<span>Показать превью изменений</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else if (actionPayload.type === 'RULES_EDITOR') {
|
||
const draftText = actionPayload.raw_draft || actionPayload.baseline_prompt || '';
|
||
const editorId = 'rules-editor-' + Date.now();
|
||
payloadHtml = `
|
||
<div class="mt-3 p-3.5 bg-slate-50 border border-slate-300 rounded-xl flex flex-col gap-2 shadow-inner">
|
||
<div class="flex items-center justify-between text-xs font-bold text-slate-700">
|
||
<span><i class="fa-solid fa-book-bookmark text-emerald-600 mr-1"></i> Редактор базы знаний и правил компании:</span>
|
||
<span class="text-[11px] text-slate-400 font-normal">Прямое изменение правил кадрового арбитража</span>
|
||
</div>
|
||
<textarea id="${editorId}" rows="12"
|
||
class="w-full text-xs font-mono p-3 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-white leading-relaxed resize-y">${escapeHtml(draftText)}</textarea>
|
||
<div class="flex items-center justify-end gap-2 pt-1">
|
||
<button type="button" onclick="window.sendChatAction('отмена')" class="px-3.5 py-1.5 text-xs text-slate-600 hover:bg-slate-200 rounded-lg transition font-medium">Отменить</button>
|
||
<button type="button" onclick="window.submitRulesDraftToDiff('${editorId}')" class="px-4 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold shadow transition flex items-center gap-1.5">
|
||
<i class="fa-solid fa-eye text-xs"></i>
|
||
<span>Показать превью изменений</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else if (actionPayload.type === 'PROMPT_PREVIEW') {
|
||
isHtmlBody = true;
|
||
}
|
||
}
|
||
|
||
let buttonsHtml = '';
|
||
if (!actionPayload || (actionPayload.type !== 'PROMPT_EDITOR' && actionPayload.type !== 'RULES_EDITOR')) {
|
||
const allButtons = (buttons && buttons.length > 0) ? buttons : (actionPayload && actionPayload.buttons ? actionPayload.buttons : []);
|
||
if (allButtons && Array.isArray(allButtons) && allButtons.length > 0) {
|
||
buttonsHtml = `
|
||
<div class="flex flex-wrap gap-2 mt-3.5 pt-2.5 border-t border-slate-100">
|
||
${allButtons.map(b => `
|
||
<button onclick="window.sendChatAction('${escapeHtml(b.value || b.action || b.title || '')}')"
|
||
class="px-3 py-1.5 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 rounded-lg text-xs font-semibold border border-indigo-200 transition">
|
||
${escapeHtml(b.label || b.title || b.action)}
|
||
</button>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
const bodyContent = isHtmlBody ? text : escapeHtml(text);
|
||
|
||
const html = `
|
||
<div class="flex gap-3 max-w-4xl mx-auto w-full pt-1">
|
||
<div class="w-8 h-8 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
|
||
<i class="fa-solid fa-robot text-sm"></i>
|
||
</div>
|
||
<div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm min-w-0">
|
||
<div class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1.5">ИИ-ассистент SCUD Orion AI</div>
|
||
<div class="text-sm text-slate-800 leading-relaxed whitespace-pre-wrap">${bodyContent}</div>
|
||
${payloadHtml}
|
||
${buttonsHtml}
|
||
</div>
|
||
</div>
|
||
`;
|
||
container.insertAdjacentHTML("beforeend", html);
|
||
}
|
||
|
||
window.filterInspectTable = function(tableId, query) {
|
||
const table = document.getElementById(tableId);
|
||
if (!table) return;
|
||
const q = (query || '').trim().toLowerCase();
|
||
const rows = table.querySelectorAll('.inspect-row');
|
||
rows.forEach(r => {
|
||
const fio = r.getAttribute('data-fio') || '';
|
||
const dept = r.getAttribute('data-dept') || '';
|
||
if (!q || fio.includes(q) || dept.includes(q)) {
|
||
r.classList.remove('hidden');
|
||
} else {
|
||
r.classList.add('hidden');
|
||
}
|
||
});
|
||
};
|
||
|
||
window.sendChatAction = function(actionText) {
|
||
if (!actionText || !actionText.trim()) return;
|
||
const input = document.getElementById("user-input");
|
||
if (input) {
|
||
input.value = actionText.trim();
|
||
updateInputHeightAndFade(input);
|
||
}
|
||
window.sendMessage();
|
||
};
|
||
|
||
window.submitPromptDraftToDiff = function(editorId) {
|
||
const textarea = document.getElementById(editorId);
|
||
if (!textarea) return;
|
||
const newText = textarea.value.trim();
|
||
if (!newText) {
|
||
alert("Текст системного промпта не может быть пустым");
|
||
return;
|
||
}
|
||
|
||
const command = `action:save_draft_prompt:::${newText}`;
|
||
const input = document.getElementById("user-input");
|
||
if (input) input.value = command;
|
||
window.sendMessage();
|
||
};
|
||
|
||
window.submitRulesDraftToDiff = function(editorId) {
|
||
const textarea = document.getElementById(editorId);
|
||
if (!textarea) return;
|
||
const newText = textarea.value.trim();
|
||
if (!newText) {
|
||
alert("Правила не могут быть пустыми");
|
||
return;
|
||
}
|
||
|
||
const command = `action:save_draft_rules:::${newText}`;
|
||
const input = document.getElementById("user-input");
|
||
if (input) input.value = command;
|
||
window.sendMessage();
|
||
};
|
||
|
||
window.sendMessage = async function() {
|
||
const input = document.getElementById("user-input");
|
||
if (!input) return;
|
||
|
||
const messageText = input.value.trim();
|
||
const fileToSend = currentAttachedFile;
|
||
|
||
if (!messageText && !fileToSend) return;
|
||
|
||
saveCommandToHistory(messageText);
|
||
|
||
input.value = "";
|
||
input.style.height = '24px';
|
||
input.style.overflowY = 'hidden';
|
||
input.style.maskImage = 'none';
|
||
input.style.webkitMaskImage = 'none';
|
||
window.clearAttachedFile();
|
||
|
||
// 1. Отрисовка сообщения пользователя в ленте
|
||
if (!messageText.startsWith("action:save_draft_prompt:::") && !messageText.startsWith("action:save_draft_rules:::")) {
|
||
appendUserMessage(
|
||
messageText || (fileToSend ? `Прикреплен файл: ${fileToSend.name}` : ''),
|
||
fileToSend ? fileToSend.name : null
|
||
);
|
||
} else {
|
||
appendUserMessage("Сформировать предпросмотр изменений");
|
||
}
|
||
|
||
// 2. Вставка лоадера
|
||
const loadingId = appendAssistantLoading();
|
||
|
||
// 3. Вызов точного скролла
|
||
scrollToUserMessageTop();
|
||
|
||
try {
|
||
let res;
|
||
if (fileToSend) {
|
||
const formData = new FormData();
|
||
formData.append("file", fileToSend);
|
||
formData.append("message", messageText);
|
||
formData.append("session_id", "web_session_main");
|
||
|
||
res = await fetch("/api/v1/chat/upload", {
|
||
method: "POST",
|
||
headers: {
|
||
"Authorization": AuthManager.getAuthHeaders()["Authorization"]
|
||
},
|
||
body: formData
|
||
});
|
||
|
||
if (res.status === 404) {
|
||
res = await fetch("/api/v1/chat", {
|
||
method: "POST",
|
||
headers: AuthManager.getAuthHeaders(),
|
||
body: JSON.stringify({
|
||
message: messageText || `Загружен файл: ${fileToSend.name}`,
|
||
session_id: "web_session_main"
|
||
})
|
||
});
|
||
}
|
||
} else {
|
||
res = await fetch("/api/v1/chat", {
|
||
method: "POST",
|
||
headers: AuthManager.getAuthHeaders(),
|
||
body: JSON.stringify({
|
||
message: messageText,
|
||
session_id: "web_session_main"
|
||
})
|
||
});
|
||
}
|
||
|
||
const loaderEl = document.getElementById(loadingId);
|
||
if (loaderEl) loaderEl.remove();
|
||
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
const replyText = data.response || data.text || data.message || "Запрос выполнен.";
|
||
const buttons = data.buttons || [];
|
||
const actionPayload = data.action_payload || null;
|
||
|
||
appendAssistantMessage(replyText, buttons, actionPayload);
|
||
|
||
if (window.SidebarManager) {
|
||
SidebarManager.renderContent();
|
||
}
|
||
} else {
|
||
const err = await res.json().catch(() => ({}));
|
||
appendAssistantMessage(`⚠️ Ошибка сервера (${res.status}): ${err.detail || "Не удалось получить ответ"}`);
|
||
}
|
||
} catch (err) {
|
||
console.error("Ошибка отправки сообщения:", err);
|
||
const loaderEl = document.getElementById(loadingId);
|
||
if (loaderEl) loaderEl.remove();
|
||
appendAssistantMessage("⚠️ Ошибка соединения с сервером при отправке сообщения.");
|
||
}
|
||
};
|
||
|
||
window.clearAttachedFile = function() {
|
||
currentAttachedFile = null;
|
||
const preview = document.getElementById("file-attachment-preview");
|
||
const nameEl = document.getElementById("file-attachment-name");
|
||
const fileInput = document.getElementById("file-upload-input");
|
||
|
||
if (preview) preview.classList.add("hidden");
|
||
if (nameEl) nameEl.innerText = "";
|
||
if (fileInput) fileInput.value = "";
|
||
};
|
||
|
||
function handleFileSelected(file) {
|
||
if (!file) return;
|
||
currentAttachedFile = file;
|
||
const preview = document.getElementById("file-attachment-preview");
|
||
const nameEl = document.getElementById("file-attachment-name");
|
||
|
||
if (preview && nameEl) {
|
||
nameEl.innerText = file.name;
|
||
preview.classList.remove("hidden");
|
||
}
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
const input = document.getElementById("user-input");
|
||
const fileInput = document.getElementById("file-upload-input");
|
||
const overlay = document.getElementById("global-drag-overlay");
|
||
|
||
if (input) {
|
||
input.style.lineHeight = '24px';
|
||
input.style.height = '24px';
|
||
|
||
input.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") {
|
||
if (e.shiftKey) return;
|
||
e.preventDefault();
|
||
window.sendMessage();
|
||
return;
|
||
}
|
||
|
||
if (e.key === "ArrowUp") {
|
||
const isSingleLine = !input.value.includes("\n");
|
||
const isAtBeginning = input.selectionStart === 0 && input.selectionEnd === 0;
|
||
|
||
if (isSingleLine || isAtBeginning) {
|
||
if (chatInputHistory.length > 0) {
|
||
e.preventDefault();
|
||
if (chatHistoryIndex === -1) {
|
||
temporaryCurrentInput = input.value;
|
||
chatHistoryIndex = chatInputHistory.length - 1;
|
||
} else if (chatHistoryIndex > 0) {
|
||
chatHistoryIndex--;
|
||
}
|
||
input.value = chatInputHistory[chatHistoryIndex];
|
||
updateInputHeightAndFade(input);
|
||
input.setSelectionRange(input.value.length, input.value.length);
|
||
}
|
||
}
|
||
} else if (e.key === "ArrowDown") {
|
||
const isSingleLine = !input.value.includes("\n");
|
||
const isAtEnd = input.selectionStart === input.value.length;
|
||
|
||
if (isSingleLine || isAtEnd) {
|
||
if (chatHistoryIndex !== -1) {
|
||
e.preventDefault();
|
||
if (chatHistoryIndex < chatInputHistory.length - 1) {
|
||
chatHistoryIndex++;
|
||
input.value = chatInputHistory[chatHistoryIndex];
|
||
} else {
|
||
chatHistoryIndex = -1;
|
||
input.value = temporaryCurrentInput;
|
||
}
|
||
updateInputHeightAndFade(input);
|
||
input.setSelectionRange(input.value.length, input.value.length);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
input.addEventListener("input", function() {
|
||
updateInputHeightAndFade(this);
|
||
chatHistoryIndex = -1;
|
||
});
|
||
}
|
||
|
||
if (fileInput) {
|
||
fileInput.addEventListener("change", (e) => {
|
||
if (e.target.files && e.target.files[0]) {
|
||
handleFileSelected(e.target.files[0]);
|
||
}
|
||
});
|
||
}
|
||
|
||
let dragCounter = 0;
|
||
|
||
window.addEventListener('dragenter', (e) => {
|
||
e.preventDefault();
|
||
dragCounter++;
|
||
if (overlay) {
|
||
overlay.classList.remove('hidden');
|
||
}
|
||
}, false);
|
||
|
||
window.addEventListener('dragleave', (e) => {
|
||
e.preventDefault();
|
||
dragCounter--;
|
||
if (dragCounter <= 0) {
|
||
dragCounter = 0;
|
||
if (overlay) {
|
||
overlay.classList.add('hidden');
|
||
}
|
||
}
|
||
}, false);
|
||
|
||
window.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
}, false);
|
||
|
||
window.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
dragCounter = 0;
|
||
if (overlay) {
|
||
overlay.classList.add('hidden');
|
||
}
|
||
|
||
const dt = e.dataTransfer;
|
||
if (dt && dt.files && dt.files[0]) {
|
||
handleFileSelected(dt.files[0]);
|
||
}
|
||
}, false);
|
||
});
|
||
```
|
||
|
||
## File: `./modules/web_api/static/js/chat/task_widget.js`
|
||
```js
|
||
/*
|
||
===============================================================================
|
||
FILE: modules/web_api/static/js/chat/task_widget.js
|
||
ROLE: Интерактивные виджеты задач и срезов СКУД (SNAPSHOTS_CARD) с чекбоксами.
|
||
===============================================================================
|
||
*/
|
||
|
||
window.activeTaskFilter = window.activeTaskFilter || 'IN_PROGRESS';
|
||
window.currentTasksCache = window.currentTasksCache || [];
|
||
|
||
function getAuthHeaders() {
|
||
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
|
||
const headers = { 'Content-Type': 'application/json' };
|
||
if (token) {
|
||
headers['Authorization'] = 'Bearer ' + token;
|
||
}
|
||
return headers;
|
||
}
|
||
|
||
window.sendChatAction = function(actionText) {
|
||
const input = document.getElementById("user-input");
|
||
if (input && typeof sendMessage === "function") {
|
||
input.value = actionText;
|
||
if (typeof setInputLocked === "function") setInputLocked(false);
|
||
sendMessage();
|
||
}
|
||
};
|
||
|
||
// ============================================================================
|
||
// РЕНДЕРИНГ КАРТОЧКИ СРЕЗОВ СКУД (SNAPSHOTS_CARD) С ЧЕКБОКСАМИ
|
||
// ============================================================================
|
||
function renderSnapshotsCard(data) {
|
||
if (!data || !data.snapshots || !Array.isArray(data.snapshots)) return '';
|
||
const queryDate = data.query_date || 'выбранную дату';
|
||
const snapshots = data.snapshots;
|
||
|
||
if (snapshots.length === 0) {
|
||
return `
|
||
<div class="p-6 bg-slate-50 border border-slate-200 rounded-xl text-center text-xs text-slate-500 my-2">
|
||
📸 За дату <b>${queryDate}</b> сохраненных снапшотов не найдено.
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
const rowsHtml = snapshots.map((s, idx) => {
|
||
const snapId = s.snapshot_id || `ID-${idx}`;
|
||
const snapTime = s.snapshot_time ? s.snapshot_time.split(' ')[1] || s.snapshot_time : '—';
|
||
const count = s.record_count || 0;
|
||
const isFinal = snapId.startsWith('Y');
|
||
|
||
if (isFinal) {
|
||
return `
|
||
<div class="p-2.5 bg-purple-50/60 rounded-lg border border-purple-200 shadow-sm flex items-center justify-between gap-3">
|
||
<div class="flex items-center gap-2.5 min-w-0">
|
||
<span class="w-4 flex justify-center text-purple-400" title="Итоговый срез защищен">
|
||
<i class="fa-solid fa-lock text-[11px]"></i>
|
||
</span>
|
||
<span class="font-mono text-xs font-bold px-2 py-0.5 rounded bg-purple-100 text-purple-800 border border-purple-300">
|
||
#${snapId}
|
||
</span>
|
||
<div class="flex items-center gap-3 text-xs text-slate-600">
|
||
<span class="flex items-center gap-1 font-semibold text-purple-900">
|
||
<i class="fa-regular fa-clock text-purple-600 text-[11px]"></i> ${snapTime}
|
||
</span>
|
||
<span class="flex items-center gap-1 text-slate-500">
|
||
<i class="fa-solid fa-users text-slate-400 text-[11px]"></i> ${count} записей
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<span class="text-[10px] font-bold text-purple-700 bg-purple-100 border border-purple-200 px-2 py-0.5 rounded">
|
||
Итоговый Y-срез
|
||
</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
return `
|
||
<div class="p-2.5 bg-white rounded-lg border border-slate-200 shadow-sm hover:border-slate-300 transition flex items-center justify-between gap-3">
|
||
<div class="flex items-center gap-2.5 min-w-0">
|
||
<input type="checkbox" value="${snapId}" onchange="window.updateSelectedSnapshots(this)"
|
||
class="snapshot-item-checkbox rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 w-4 h-4 cursor-pointer">
|
||
<span class="font-mono text-xs font-bold px-2 py-0.5 rounded bg-slate-100 text-slate-700 border border-slate-200">
|
||
#${snapId}
|
||
</span>
|
||
<div class="flex items-center gap-3 text-xs text-slate-600">
|
||
<span class="flex items-center gap-1 font-semibold text-slate-800">
|
||
<i class="fa-regular fa-clock text-indigo-500 text-[11px]"></i> ${snapTime}
|
||
</span>
|
||
<span class="flex items-center gap-1 text-slate-500">
|
||
<i class="fa-solid fa-users text-slate-400 text-[11px]"></i> ${count} записей
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<button type="button" onclick="window.sendChatAction('удали снапшот ${snapId}')"
|
||
class="p-1 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded border border-transparent hover:border-rose-200 transition"
|
||
title="Удалить срез">
|
||
<i class="fa-solid fa-trash-can text-xs"></i>
|
||
</button>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
return `
|
||
<div class="snapshots-widget-root w-full max-w-4xl mx-auto my-2 bg-slate-50 border border-slate-300 rounded-xl shadow-md overflow-hidden flex flex-col">
|
||
<div class="px-4 py-2.5 bg-white border-b border-slate-200 flex items-center justify-between gap-2 flex-wrap">
|
||
<div class="flex items-center gap-2">
|
||
<div class="bg-indigo-600 text-white p-1.5 rounded-lg flex items-center justify-center">
|
||
<i class="fa-solid fa-camera text-xs"></i>
|
||
</div>
|
||
<div>
|
||
<span class="text-xs font-bold text-slate-800">Реестр срезов СКУД</span>
|
||
<span class="text-xs text-slate-500 ml-1">за ${queryDate}</span>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-3">
|
||
<label class="flex items-center gap-1.5 text-xs text-slate-600 cursor-pointer select-none">
|
||
<input type="checkbox" onchange="window.toggleSelectAllSnapshots(this)" class="select-all-snapshots-cb rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 w-3.5 h-3.5">
|
||
<span>Выбрать все</span>
|
||
</label>
|
||
<span class="text-[11px] font-semibold bg-indigo-50 text-indigo-700 border border-indigo-200 px-2 py-0.5 rounded-full">
|
||
Срезов: ${snapshots.length}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="p-3 flex flex-col gap-2">
|
||
${rowsHtml}
|
||
</div>
|
||
|
||
<!-- ПОДВАЛ С КНОПКОЙ УДАЛЕНИЯ ВЫБРАННЫХ -->
|
||
<div class="snapshot-bulk-actions-footer hidden px-4 py-2 bg-rose-50/70 border-t border-rose-200 flex items-center justify-between">
|
||
<span class="text-xs text-rose-800 font-medium bulk-selected-counter">Выбрано: 0</span>
|
||
<button type="button" onclick="window.submitBulkDeleteSnapshots(this)"
|
||
class="px-3 py-1.5 bg-rose-600 hover:bg-rose-700 active:bg-rose-800 text-white font-bold rounded-lg text-xs flex items-center gap-1.5 transition shadow-sm">
|
||
<i class="fa-solid fa-trash-can"></i>
|
||
<span>Удалить выбранные</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ⭐️ Обработчики чекбоксов
|
||
window.toggleSelectAllSnapshots = function(masterCb) {
|
||
const root = masterCb.closest('.snapshots-widget-root');
|
||
if (!root) return;
|
||
const checkboxes = root.querySelectorAll('.snapshot-item-checkbox');
|
||
checkboxes.forEach(cb => cb.checked = masterCb.checked);
|
||
window.syncBulkDeleteFooter(root);
|
||
};
|
||
|
||
window.updateSelectedSnapshots = function(itemCb) {
|
||
const root = itemCb.closest('.snapshots-widget-root');
|
||
if (!root) return;
|
||
window.syncBulkDeleteFooter(root);
|
||
};
|
||
|
||
window.syncBulkDeleteFooter = function(root) {
|
||
const checkboxes = root.querySelectorAll('.snapshot-item-checkbox:checked');
|
||
const footer = root.querySelector('.snapshot-bulk-actions-footer');
|
||
const counter = root.querySelector('.bulk-selected-counter');
|
||
const masterCb = root.querySelector('.select-all-snapshots-cb');
|
||
const allCheckboxes = root.querySelectorAll('.snapshot-item-checkbox');
|
||
|
||
if (masterCb) {
|
||
masterCb.checked = allCheckboxes.length > 0 && checkboxes.length === allCheckboxes.length;
|
||
}
|
||
|
||
if (checkboxes.length > 0) {
|
||
if (footer) footer.classList.remove('hidden');
|
||
if (counter) counter.innerText = `Выбрано дневных срезов: ${checkboxes.length}`;
|
||
} else {
|
||
if (footer) footer.classList.add('hidden');
|
||
}
|
||
};
|
||
|
||
// Одиночная корзина в строке снапшота:
|
||
// onclick="window.sendChatAction('удали снапшот ${snapId}')"
|
||
|
||
// Кнопка пакетного удаления в подвале карточки:
|
||
window.submitBulkDeleteSnapshots = function(btnEl) {
|
||
const root = btnEl.closest('.snapshots-widget-root');
|
||
if (!root) return;
|
||
const selected = Array.from(root.querySelectorAll('.snapshot-item-checkbox:checked')).map(cb => cb.value);
|
||
if (selected.length === 0) return;
|
||
window.sendChatAction(`удали снапшоты ${selected.join(', ')}`);
|
||
};
|
||
|
||
// ============================================================================
|
||
// РЕНДЕРИНГ КАРТОЧКИ ЗАДАЧ (TASK_INTERACTIVE_CARD)
|
||
// ============================================================================
|
||
function renderInteractiveTaskCard(tasks) {
|
||
if (!tasks || !Array.isArray(tasks)) return '';
|
||
window.currentTasksCache = tasks;
|
||
|
||
const counts = {
|
||
ALL: tasks.length,
|
||
IN_PROGRESS: tasks.filter(t => t.status === 'IN_PROGRESS' || t.status === 'PROGRESS').length,
|
||
PLANNED: tasks.filter(t => t.status === 'BACKLOG' || t.status === 'PLANNED').length,
|
||
COMPLETED: tasks.filter(t => t.status === 'COMPLETED' || t.status === 'DONE').length
|
||
};
|
||
|
||
const currentFilter = window.activeTaskFilter || 'IN_PROGRESS';
|
||
|
||
const filteredTasks = tasks.filter(t => {
|
||
const s = (t.status || 'BACKLOG').toUpperCase();
|
||
if (currentFilter === 'ALL') return true;
|
||
if (currentFilter === 'IN_PROGRESS') return s === 'IN_PROGRESS' || s === 'PROGRESS';
|
||
if (currentFilter === 'PLANNED') return s === 'BACKLOG' || s === 'PLANNED';
|
||
if (currentFilter === 'COMPLETED') return s === 'COMPLETED' || s === 'DONE';
|
||
return true;
|
||
});
|
||
|
||
const getPrioBadge = (prio) => {
|
||
const p = (prio || 'MEDIUM').toUpperCase();
|
||
if (p === 'HIGH' || p === 'CRITICAL') return '<span class="text-[10px] px-2 py-0.5 rounded font-bold bg-rose-100 text-rose-700 border border-rose-200">🔥 HIGH</span>';
|
||
if (p === 'LOW') return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-slate-100 text-slate-600 border border-slate-200">☕ LOW</span>';
|
||
return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-amber-100 text-amber-700 border border-amber-200">⚡ MEDIUM</span>';
|
||
};
|
||
|
||
const getStatusBadge = (status) => {
|
||
const s = (status || 'BACKLOG').toUpperCase();
|
||
if (s === 'IN_PROGRESS' || s === 'PROGRESS') return '<span class="text-[10px] px-2 py-0.5 rounded font-bold bg-blue-50 text-blue-700 border border-blue-200">⚙️ В работе</span>';
|
||
if (s === 'COMPLETED' || s === 'DONE') return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">✓ Готово</span>';
|
||
return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-slate-50 text-slate-600 border border-slate-200">📋 В планах</span>';
|
||
};
|
||
|
||
const taskRows = filteredTasks.map(t => {
|
||
const id = t.id;
|
||
const title = t.title || 'Без названия';
|
||
const isDone = t.status === 'COMPLETED' || t.status === 'DONE';
|
||
const isInProgress = t.status === 'IN_PROGRESS' || t.status === 'PROGRESS';
|
||
|
||
const actionBtn = isInProgress
|
||
? `<button type="button" onclick="window.sendChatAction('заверши задачу ${id}')" class="px-2.5 py-1 text-xs font-semibold rounded bg-emerald-50 text-emerald-700 border border-emerald-300 hover:bg-emerald-100 transition-colors shadow-sm" title="Завершить задачу">✓ Готово</button>`
|
||
: (!isDone
|
||
? `<button type="button" onclick="window.sendChatAction('возьми в работу задачу ${id}')" class="px-2.5 py-1 text-xs font-semibold rounded bg-blue-50 text-blue-700 border border-blue-300 hover:bg-blue-100 transition-colors shadow-sm" title="Взять в работу">⚙️ В работу</button>`
|
||
: '');
|
||
|
||
return `
|
||
<div id="task-card-${id}" class="p-3 bg-white rounded-lg border border-slate-200 shadow-sm hover:border-slate-300 transition-all flex flex-col gap-2">
|
||
<div class="task-view-mode flex items-center justify-between gap-3">
|
||
<div class="flex items-center gap-2 flex-wrap flex-1 min-w-0">
|
||
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-slate-100 text-slate-700 border border-slate-200">#${id}</span>
|
||
<span class="text-sm font-medium text-slate-900 truncate" title="${title}">${title}</span>
|
||
</div>
|
||
<div class="flex items-center gap-1.5 shrink-0">
|
||
${actionBtn}
|
||
<button type="button" onclick="window.openTaskInlineEditor(${id})" class="p-1 text-xs text-slate-500 hover:text-indigo-600 hover:bg-slate-50 rounded border border-slate-200 transition-colors" title="Редактировать">✏️</button>
|
||
<button type="button" onclick="window.sendChatAction('удали задачу ${id}')" class="p-1 text-xs text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded border border-slate-200 transition-colors" title="Удалить">🗑️</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="task-view-mode flex items-center gap-2 text-xs text-slate-500 flex-wrap">
|
||
${getPrioBadge(t.priority)}
|
||
${getStatusBadge(t.status)}
|
||
<span class="px-1.5 py-0.5 rounded bg-slate-50 border border-slate-200 text-slate-600 font-mono text-[11px]">${t.module || 'general'}</span>
|
||
${t.due_date ? `<span class="text-slate-500">📅 срок: <b>${t.due_date}</b></span>` : ''}
|
||
${t.created_at ? `<span class="text-slate-400">создана: ${t.created_at.split(' ')[0]}</span>` : ''}
|
||
</div>
|
||
|
||
<!-- ФОРМА ИНЛАЙН РЕДАКТИРОВАНИЯ -->
|
||
<div id="task-editor-${id}" class="hidden flex flex-col gap-2 pt-2 border-t border-slate-100">
|
||
<input type="text" id="task-edit-title-${id}" value="${title.replace(/"/g, '"')}" class="w-full text-xs px-2.5 py-1.5 border border-slate-300 rounded focus:border-indigo-500 focus:outline-none bg-slate-50" placeholder="Описание задачи..." />
|
||
<div class="flex items-center gap-2 flex-wrap">
|
||
<input type="date" id="task-edit-date-${id}" value="${t.due_date || ''}" class="text-xs px-2 py-1 border border-slate-300 rounded focus:border-indigo-500 focus:outline-none bg-slate-50" />
|
||
<select id="task-edit-prio-${id}" class="text-xs px-2 py-1 border border-slate-300 rounded focus:border-indigo-500 focus:outline-none bg-slate-50">
|
||
<option value="LOW" ${t.priority === 'LOW' ? 'selected' : ''}>☕ LOW</option>
|
||
<option value="MEDIUM" ${t.priority === 'MEDIUM' || !t.priority ? 'selected' : ''}>⚡ MEDIUM</option>
|
||
<option value="HIGH" ${t.priority === 'HIGH' ? 'selected' : ''}>🔥 HIGH</option>
|
||
<option value="CRITICAL" ${t.priority === 'CRITICAL' ? 'selected' : ''}>🚨 CRITICAL</option>
|
||
</select>
|
||
<button type="button" onclick="window.saveTaskInlineEdit(${id})" class="px-2.5 py-1 text-xs font-semibold rounded bg-indigo-600 text-white hover:bg-indigo-700 transition-colors shadow-sm">Сохранить</button>
|
||
<button type="button" onclick="window.closeTaskInlineEditor(${id})" class="px-2 py-1 text-xs font-medium rounded text-slate-500 hover:bg-slate-100 transition-colors">Отмена</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
const emptyState = `
|
||
<div class="p-8 text-center text-slate-400">
|
||
<div class="text-3xl mb-2">📭</div>
|
||
<div class="text-sm font-medium">Нет задач в категории «${currentFilter}»</div>
|
||
</div>
|
||
`;
|
||
|
||
return `
|
||
<div class="task-widget-root w-full max-w-4xl mx-auto my-2 bg-slate-50 border border-slate-300 rounded-xl shadow-md overflow-hidden flex flex-col">
|
||
<div class="px-4 py-3 bg-white border-b border-slate-200 flex items-center justify-between gap-2 flex-wrap">
|
||
<div class="flex items-center gap-1.5 flex-wrap">
|
||
<button type="button" onclick="window.switchTaskFilter('IN_PROGRESS', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'IN_PROGRESS' ? 'bg-blue-600 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
|
||
⚙️ В работе (${counts.IN_PROGRESS})
|
||
</button>
|
||
<button type="button" onclick="window.switchTaskFilter('PLANNED', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'PLANNED' ? 'bg-indigo-600 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
|
||
📋 В планах (${counts.PLANNED})
|
||
</button>
|
||
<button type="button" onclick="window.switchTaskFilter('COMPLETED', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'COMPLETED' ? 'bg-emerald-600 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
|
||
✓ Готово (${counts.COMPLETED})
|
||
</button>
|
||
<button type="button" onclick="window.switchTaskFilter('ALL', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'ALL' ? 'bg-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
|
||
Все (${counts.ALL})
|
||
</button>
|
||
</div>
|
||
|
||
<button type="button" onclick="window.toggleCreateTaskForm(this)" class="px-3 py-1.5 text-xs font-bold rounded-md bg-indigo-600 hover:bg-indigo-700 text-white transition-all shadow-sm flex items-center gap-1">
|
||
➕ Добавить задачу
|
||
</button>
|
||
</div>
|
||
|
||
<div class="new-task-creation-form hidden p-3 bg-indigo-50/70 border-b border-indigo-100 flex flex-col gap-2">
|
||
<div class="text-xs font-bold text-indigo-900">Новая задача:</div>
|
||
<input type="text" class="new-task-title w-full text-xs px-2.5 py-1.5 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white" placeholder="Что необходимо сделать?..." />
|
||
<div class="flex items-center gap-2 flex-wrap">
|
||
<input type="date" class="new-task-date text-xs px-2 py-1 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white" />
|
||
<select class="new-task-prio text-xs px-2 py-1 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white">
|
||
<option value="LOW">☕ LOW (Низкий)</option>
|
||
<option value="MEDIUM" selected>⚡ MEDIUM (Средний)</option>
|
||
<option value="HIGH">🔥 HIGH (Высокий)</option>
|
||
<option value="CRITICAL">🚨 CRITICAL (Критический)</option>
|
||
</select>
|
||
<select class="new-task-status text-xs px-2 py-1 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white">
|
||
<option value="BACKLOG" selected>📋 В планы (Бэклог)</option>
|
||
<option value="IN_PROGRESS">⚙️ Сразу в работу</option>
|
||
</select>
|
||
<button type="button" onclick="window.submitCreateTask(this)" class="px-3 py-1 text-xs font-bold rounded bg-indigo-600 text-white hover:bg-indigo-700 transition-colors shadow-sm">Создать</button>
|
||
<button type="button" onclick="window.toggleCreateTaskForm(this)" class="px-2 py-1 text-xs font-medium rounded text-slate-500 hover:bg-slate-200 transition-colors">Отмена</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="p-3 overflow-y-auto max-h-[70vh] flex flex-col gap-2 task-rows-container">
|
||
${filteredTasks.length > 0 ? taskRows : emptyState}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
window.switchTaskFilter = function(filterName, btnEl) {
|
||
window.activeTaskFilter = filterName;
|
||
const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root');
|
||
if (root && window.currentTasksCache && window.currentTasksCache.length > 0) {
|
||
root.outerHTML = renderInteractiveTaskCard(window.currentTasksCache);
|
||
}
|
||
};
|
||
|
||
window.toggleCreateTaskForm = function(btnEl) {
|
||
const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root');
|
||
if (!root) return;
|
||
const form = root.querySelector('.new-task-creation-form');
|
||
if (form) {
|
||
form.classList.toggle('hidden');
|
||
if (!form.classList.contains('hidden')) {
|
||
const input = form.querySelector('.new-task-title');
|
||
if (input) input.focus();
|
||
}
|
||
}
|
||
};
|
||
|
||
window.submitCreateTask = async function(btnEl) {
|
||
const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root');
|
||
if (!root) return;
|
||
|
||
const titleInput = root.querySelector('.new-task-title');
|
||
const dateInput = root.querySelector('.new-task-date');
|
||
const prioInput = root.querySelector('.new-task-prio');
|
||
const statusInput = root.querySelector('.new-task-status');
|
||
|
||
if (!titleInput || !titleInput.value.trim()) {
|
||
alert('Введите описание задачи');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch('/api/v1/tasks', {
|
||
method: 'POST',
|
||
headers: getAuthHeaders(),
|
||
body: JSON.stringify({
|
||
title: titleInput.value.trim(),
|
||
due_date: dateInput ? (dateInput.value || null) : null,
|
||
priority: prioInput ? prioInput.value : 'MEDIUM',
|
||
status: statusInput ? statusInput.value : 'BACKLOG'
|
||
})
|
||
});
|
||
|
||
if (res.ok) {
|
||
window.activeTaskFilter = (statusInput && statusInput.value === 'IN_PROGRESS') ? 'IN_PROGRESS' : 'PLANNED';
|
||
window.sendChatAction('покажи задачи');
|
||
} else {
|
||
const err = await res.json();
|
||
alert('Ошибка создания задачи: ' + (err.detail || 'Неизвестная ошибка'));
|
||
}
|
||
} catch (e) {
|
||
console.error('Ошибка создания задачи:', e);
|
||
alert('Сетевая ошибка при создании задачи');
|
||
}
|
||
};
|
||
|
||
window.openTaskInlineEditor = function(id) {
|
||
const card = document.getElementById(`task-card-${id}`);
|
||
if (!card) return;
|
||
card.querySelectorAll('.task-view-mode').forEach(el => el.classList.add('hidden'));
|
||
const editor = document.getElementById(`task-editor-${id}`);
|
||
if (editor) editor.classList.remove('hidden');
|
||
};
|
||
|
||
window.closeTaskInlineEditor = function(id) {
|
||
const card = document.getElementById(`task-card-${id}`);
|
||
if (!card) return;
|
||
card.querySelectorAll('.task-view-mode').forEach(el => el.classList.remove('hidden'));
|
||
const editor = document.getElementById(`task-editor-${id}`);
|
||
if (editor) editor.classList.add('hidden');
|
||
};
|
||
|
||
window.saveTaskInlineEdit = async function(id) {
|
||
const titleInput = document.getElementById(`task-edit-title-${id}`);
|
||
const dateInput = document.getElementById(`task-edit-date-${id}`);
|
||
const prioInput = document.getElementById(`task-edit-prio-${id}`);
|
||
|
||
if (!titleInput || !titleInput.value.trim()) {
|
||
alert('Описание задачи не может быть пустым');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch(`/api/v1/tasks/${id}`, {
|
||
method: 'PATCH',
|
||
headers: getAuthHeaders(),
|
||
body: JSON.stringify({
|
||
title: titleInput.value.trim(),
|
||
due_date: dateInput ? (dateInput.value || null) : null,
|
||
priority: prioInput ? prioInput.value : 'MEDIUM'
|
||
})
|
||
});
|
||
|
||
if (res.ok) {
|
||
window.sendChatAction('покажи задачи');
|
||
} else {
|
||
const err = await res.json();
|
||
alert('Ошибка обновления задачи: ' + (err.detail || 'Неизвестная ошибка'));
|
||
}
|
||
} catch (e) {
|
||
console.error('Ошибка сохранения задачи:', e);
|
||
alert('Сетевая ошибка при обновлении задачи');
|
||
}
|
||
};
|
||
```
|
||
|
||
## File: `./modules/web_api/static/js/auth.js`
|
||
```js
|
||
/**
|
||
* ===============================================================================
|
||
* FILE: modules/web_api/static/js/auth.js
|
||
* ROLE: Менеджер сессий, токенов, ФИО и авторизационных заголовков.
|
||
* ===============================================================================
|
||
*/
|
||
|
||
const AUTH_STORAGE_KEY = "scud_auth_token";
|
||
const USERNAME_STORAGE_KEY = "scud_username";
|
||
const FULLNAME_STORAGE_KEY = "scud_full_name";
|
||
const IS_ADMIN_STORAGE_KEY = "scud_is_admin";
|
||
const USER_ID_STORAGE_KEY = "scud_user_id";
|
||
|
||
const AuthManager = {
|
||
getToken() {
|
||
return localStorage.getItem(AUTH_STORAGE_KEY) || "";
|
||
},
|
||
|
||
getUserId() {
|
||
const uid = localStorage.getItem(USER_ID_STORAGE_KEY);
|
||
return uid ? parseInt(uid, 10) : 1;
|
||
},
|
||
|
||
getUsername() {
|
||
return localStorage.getItem(USERNAME_STORAGE_KEY) || "";
|
||
},
|
||
|
||
getFullName() {
|
||
return localStorage.getItem(FULLNAME_STORAGE_KEY) || this.getUsername() || "Пользователь";
|
||
},
|
||
|
||
isAdmin() {
|
||
return localStorage.getItem(IS_ADMIN_STORAGE_KEY) === "true";
|
||
},
|
||
|
||
isAuthenticated() {
|
||
return Boolean(this.getToken());
|
||
},
|
||
|
||
setSession(token, username, fullName, isAdmin, userId = 1) {
|
||
localStorage.setItem(AUTH_STORAGE_KEY, token);
|
||
localStorage.setItem(USERNAME_STORAGE_KEY, username);
|
||
localStorage.setItem(FULLNAME_STORAGE_KEY, fullName || username);
|
||
localStorage.setItem(IS_ADMIN_STORAGE_KEY, String(isAdmin));
|
||
localStorage.setItem(USER_ID_STORAGE_KEY, String(userId));
|
||
localStorage.setItem("scud_api_auth_token", token);
|
||
localStorage.setItem("auth_token", token);
|
||
},
|
||
|
||
getAuthHeaders() {
|
||
const token = this.getToken();
|
||
const headers = { "Content-Type": "application/json" };
|
||
if (token) {
|
||
headers["Authorization"] = `Bearer ${token}`;
|
||
}
|
||
return headers;
|
||
},
|
||
|
||
logout() {
|
||
console.log("[Auth] Полный выход из системы...");
|
||
localStorage.clear();
|
||
sessionStorage.clear();
|
||
|
||
document.cookie.split(";").forEach((cookie) => {
|
||
const eqPos = cookie.indexOf("=");
|
||
const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim();
|
||
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`;
|
||
});
|
||
|
||
window.location.href = "/";
|
||
}
|
||
};
|
||
|
||
window.AuthManager = AuthManager;
|
||
window.logout = () => AuthManager.logout();
|
||
```
|
||
|
||
## File: `./modules/web_api/static/js/app.js`
|
||
```js
|
||
const AUTH_TOKEN_KEY = "scud_api_auth_token";
|
||
const SESSION_ID = "web_session_main";
|
||
const STORAGE_KEY = "scud_chat_input_history";
|
||
|
||
let API_TOKEN = localStorage.getItem(AUTH_TOKEN_KEY) || "";
|
||
let CURRENT_USERNAME = localStorage.getItem("scud_username") || "";
|
||
let IS_ADMIN = localStorage.getItem("scud_is_admin") === "true";
|
||
let IS_GUEST = localStorage.getItem("scud_is_guest") === "true";
|
||
|
||
let inputHistory = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
|
||
let historyIndex = -1;
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
const userInputEl = document.getElementById("user-input");
|
||
|
||
if (userInputEl) {
|
||
userInputEl.addEventListener("input", function() {
|
||
this.style.height = "24px";
|
||
const newHeight = Math.min(this.scrollHeight, 120);
|
||
this.style.height = newHeight + "px";
|
||
});
|
||
}
|
||
|
||
if (IS_GUEST) {
|
||
hideAuthModal();
|
||
updateUIState();
|
||
} else if (API_TOKEN) {
|
||
hideAuthModal();
|
||
updateUIState();
|
||
if (typeof loadTasks === "function") {
|
||
loadTasks();
|
||
}
|
||
} else {
|
||
showAuthModal();
|
||
}
|
||
});
|
||
```
|