3311 lines
150 KiB
Markdown
3311 lines
150 KiB
Markdown
# 🌐 WEB API & LLM AGENT 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.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)
|
||
|
||
# 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/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/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/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/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/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":
|
||
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}. При любых вопросах о сотрудниках, их времени входа/выхода, отделах или присутствии — "
|
||
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/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/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": "Получение списка снапшотов и срезов логов СКУД из базы данных за конкретную дату.",
|
||
"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/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/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/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/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/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();
|
||
}
|
||
});
|
||
```
|
||
|
||
## 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/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/chat/core.js`
|
||
```js
|
||
/**
|
||
* ===============================================================================
|
||
* FILE: modules/web_api/static/js/chat/core.js
|
||
* ROLE: Ядро чата: полноэкранный Drag-and-Drop оверлей, прецизионный скролл
|
||
* вопроса к верху окна, авто-высота инпута, крупный шрифт text-sm.
|
||
* ===============================================================================
|
||
*/
|
||
|
||
let currentAttachedFile = null;
|
||
|
||
const STORAGE_KEY = "scud_chat_input_history";
|
||
let chatInputHistory = JSON.parse(localStorage.getItem(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(STORAGE_KEY, JSON.stringify(chatInputHistory));
|
||
chatHistoryIndex = -1;
|
||
}
|
||
|
||
// Поиск скролл-контейнера
|
||
function getActualScrollContainer() {
|
||
const listEl = document.getElementById("chat-messages-container");
|
||
if (!listEl) return window;
|
||
|
||
let parent = listEl;
|
||
while (parent && parent !== document.body) {
|
||
const style = window.getComputedStyle(parent);
|
||
if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
|
||
return parent;
|
||
}
|
||
parent = parent.parentElement;
|
||
}
|
||
return document.querySelector("main") || window;
|
||
}
|
||
|
||
// Прецизионный скролл сообщения оператора к верхнему краю экрана
|
||
function scrollToUserMessageTop(element) {
|
||
if (!element) return;
|
||
|
||
setTimeout(() => {
|
||
const scroller = getActualScrollContainer();
|
||
if (scroller && scroller !== window) {
|
||
const scrollerRect = scroller.getBoundingClientRect();
|
||
const elRect = element.getBoundingClientRect();
|
||
const currentScroll = scroller.scrollTop;
|
||
const targetScroll = currentScroll + (elRect.top - scrollerRect.top) - 16;
|
||
|
||
scroller.scrollTo({
|
||
top: Math.max(targetScroll, 0),
|
||
behavior: 'smooth'
|
||
});
|
||
} else {
|
||
element.scrollIntoView({
|
||
behavior: 'smooth',
|
||
block: 'start'
|
||
});
|
||
}
|
||
}, 80);
|
||
}
|
||
|
||
function scrollToBottom() {
|
||
const scroller = getActualScrollContainer();
|
||
if (scroller && scroller !== window) {
|
||
scroller.scrollTop = scroller.scrollHeight;
|
||
}
|
||
}
|
||
|
||
function updateInputHeightAndFade(textarea) {
|
||
if (!textarea) return;
|
||
|
||
if (!textarea.value || textarea.value.trim() === '') {
|
||
textarea.style.height = '22px';
|
||
textarea.style.overflowY = 'hidden';
|
||
textarea.style.maskImage = 'none';
|
||
textarea.style.webkitMaskImage = 'none';
|
||
return;
|
||
}
|
||
|
||
textarea.style.height = 'auto';
|
||
const minHeight = 22;
|
||
const maxHeight = 110;
|
||
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="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);
|
||
|
||
const userEl = document.getElementById(msgId);
|
||
scrollToUserMessageTop(userEl);
|
||
}
|
||
|
||
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 = '22px';
|
||
input.style.overflowY = 'hidden';
|
||
input.style.maskImage = 'none';
|
||
input.style.webkitMaskImage = 'none';
|
||
window.clearAttachedFile();
|
||
|
||
if (!messageText.startsWith("action:save_draft_prompt:::") && !messageText.startsWith("action:save_draft_rules:::")) {
|
||
appendUserMessage(
|
||
messageText || (fileToSend ? `Прикреплен файл: ${fileToSend.name}` : ''),
|
||
fileToSend ? fileToSend.name : null
|
||
);
|
||
} else {
|
||
appendUserMessage("Сформировать предпросмотр изменений");
|
||
}
|
||
|
||
const loadingId = appendAssistantLoading();
|
||
|
||
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 = '22px';
|
||
input.style.height = '22px';
|
||
|
||
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]);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ⭐️ Глобальный Drag-and-Drop на всё окно браузера
|
||
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('Сетевая ошибка при обновлении задачи');
|
||
}
|
||
};
|
||
```
|