diff --git a/docker-compose.yml b/docker-compose.yml index b0bb52f..a5b3179 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,8 +11,8 @@ services: - ./data/db:/var/lib/postgresql/data networks: - proxy - #ports: # !только локальный тест! - # - 5432:5432 + # ports: # !только локальный тест! + # - 5432:5432 env_file: - ./.env healthcheck: @@ -33,8 +33,8 @@ services: - ./data/llm:/root/.ollama networks: - proxy - #ports: # !только локальный тест! - # - 11434:11434 + # ports: # !только локальный тест! + # - 11434:11434 env_file: - ./.env healthcheck: diff --git a/example-docker.env b/example-docker.env index ef04591..cdc1e4c 100644 --- a/example-docker.env +++ b/example-docker.env @@ -6,6 +6,7 @@ POSTGRES_PASSWORD=postgres POSTGRES_DB=lib # Ollama +ASSISTANT_LLM="qwen3:4b" OLLAMA_URL="http://llm:11434" OLLAMA_MAX_LOADED_MODELS=2 OLLAMA_NUM_THREADS=4 diff --git a/example-local.env b/example-local.env index 39f8611..9638fcd 100644 --- a/example-local.env +++ b/example-local.env @@ -6,6 +6,7 @@ POSTGRES_PASSWORD=postgres POSTGRES_DB=lib # Ollama +ASSISTANT_LLM="qwen3:4b" OLLAMA_URL="http://localhost:11434" OLLAMA_MAX_LOADED_MODELS=2 OLLAMA_NUM_THREADS=4 diff --git a/library_service/auth/__init__.py b/library_service/auth/__init__.py index 3ac004a..db034ba 100644 --- a/library_service/auth/__init__.py +++ b/library_service/auth/__init__.py @@ -37,11 +37,13 @@ from .core import ( is_user_admin, OptionalAuth, RequireAuth, + RequireAuthWS, RequireAdmin, RequireMember, RequireLibrarian, RequirePartialAuth, RequireStaff, + RequireStaffWS, ) from .seed import ( @@ -100,10 +102,12 @@ __all__ = [ "is_user_admin", "OptionalAuth", "RequireAuth", + "RequireAuthWS", "RequireAdmin", "RequireMember", "RequireLibrarian", "RequireStaff", + "RequireStaffWS", "seed_roles", "seed_admin", "run_seeds", diff --git a/library_service/auth/core.py b/library_service/auth/core.py index 20e83de..b202ac7 100644 --- a/library_service/auth/core.py +++ b/library_service/auth/core.py @@ -8,6 +8,7 @@ import os from argon2.low_level import hash_secret_raw, Type from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from fastapi import WebSocket, WebSocketException, Query from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from jose import jwt, JWTError, ExpiredSignatureError @@ -251,6 +252,31 @@ def get_current_user( return user +async def get_current_user_ws( + websocket: WebSocket, + token: Annotated[str | None, Query()] = None, + session: Session = Depends(get_session), +) -> User: + """Аутентификация для WebSocket через Query параметр.""" + if token is None: + raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION, reason="Token required") + + try: + token_data = decode_token(token) + + user = session.get(User, token_data.user_id) + if user is None: + raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION, reason="User not found") + + if not user.is_active: + raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION, reason="Inactive user") + + return user + + except HTTPException: + raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION, reason="Invalid token") + + def get_current_active_user( current_user: Annotated[User, Depends(get_current_user)], ) -> User: @@ -315,14 +341,31 @@ def require_any_role(allowed_roles: list[str]): return role_checker +def require_any_role_ws(allowed_roles: list[str]): + """Создает dependency для WebSocket проверки наличия хотя бы одной из ролей""" + + def role_checker(current_user: User = Depends(get_current_user_ws)) -> User: + user_roles = {role.name for role in current_user.roles} + if not (user_roles & set(allowed_roles)): + raise WebSocketException( + code=status.WS_1008_POLICY_VIOLATION, + reason=f"Requires one of roles: {allowed_roles}", + ) + return current_user + + return role_checker + + # Создание dependencies OptionalAuth = Annotated[User | None, Depends(get_optional_user)] RequireAuth = Annotated[User, Depends(get_current_active_user)] +RequireAuthWS = Annotated[User, Depends(get_current_user_ws)] RequireAdmin = Annotated[User, Depends(require_role("admin"))] RequireMember = Annotated[User, Depends(require_role("member"))] RequireLibrarian = Annotated[User, Depends(require_role("librarian"))] RequirePartialAuth = Annotated[User, Depends(get_user_from_partial_token)] RequireStaff = Annotated[User, Depends(require_any_role(["admin", "librarian"]))] +RequireStaffWS = Annotated[User, Depends(require_any_role_ws(["admin", "librarian"]))] def is_user_staff(user: User) -> bool: diff --git a/library_service/main.py b/library_service/main.py index a91d258..0e2516b 100644 --- a/library_service/main.py +++ b/library_service/main.py @@ -1,36 +1,31 @@ """Основной модуль""" +from starlette.middleware.base import BaseHTTPMiddleware -import asyncio, psutil, sys, traceback +import asyncio, sys, traceback from contextlib import asynccontextmanager -from datetime import datetime from pathlib import Path -from time import perf_counter -from uuid import uuid4 from alembic import command from alembic.config import Config -from fastapi import FastAPI, Depends, Request, Response, status, HTTPException +from fastapi import status, Request, Response, HTTPException from fastapi.staticfiles import StaticFiles -from fastapi.responses import JSONResponse from ollama import Client, ResponseError from sqlmodel import Session from library_service.auth import run_seeds from library_service.routers import api_router -from library_service.routers.misc import unknown from library_service.services.captcha import limiter, cleanup_task, require_captcha +from library_service.middlewares import catch_exception_middleware, log_request_middleware, not_found_handler from library_service.settings import ( LOGGING_CONFIG, engine, get_app, get_logger, OLLAMA_URL, + ASSISTANT_LLM, ) -SKIP_LOGGING_PATHS = frozenset({"/favicon.ico", "/favicon.svg"}) - - @asynccontextmanager async def lifespan(_): """Жизненный цикл сервиса""" @@ -60,10 +55,10 @@ async def lifespan(_): ollama_client = Client(host=OLLAMA_URL) ollama_client.pull("mxbai-embed-large") - total_memory_bytes = psutil.virtual_memory().total - total_memory_gb = total_memory_bytes / (1024 ** 3) - if total_memory_gb > 5: - ollama_client.pull("qwen3:4b") + if ASSISTANT_LLM: + ollama_client.pull(ASSISTANT_LLM) + else: + logger.info("[=] AI-assistant is not available") except ResponseError as e: logger.error(f"[-] Failed to pull models {e}") @@ -75,108 +70,9 @@ async def lifespan(_): app = get_app(lifespan) - - -@app.exception_handler(status.HTTP_404_NOT_FOUND) -async def custom_not_found_handler(request: Request, exc: HTTPException): - if exc.detail == "Not Found": - path = request.url.path - - if path.startswith("/api/"): - return JSONResponse( - status_code=status.HTTP_404_NOT_FOUND, - content={"detail": "API endpoint not found", "path": path}, - ) - return await unknown(request, app) - - return JSONResponse( - status_code=exc.status_code, - content={"detail": exc.detail}, - ) - - -@app.middleware("http") -async def catch_exceptions_middleware(request: Request, call_next): - """Middleware для подробного json-описания Internal error""" - try: - return await call_next(request) - except Exception as exc: - exc_type, exc_value, exc_tb = sys.exc_info() - logger = get_logger() - logger.exception(exc) - - return JSONResponse( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={ - "message": str(exc), - "type": exc_type.__name__ if exc_type else "Unknown", - "path": str(request.url), - "method": request.method, - }, - ) - - -@app.middleware("http") -async def log_requests(request: Request, call_next): - """Middleware для логирования HTTP-запросов""" - path = request.url.path - if path.startswith("/static") or path in SKIP_LOGGING_PATHS: - return await call_next(request) - - logger = get_logger() - request_id = uuid4().hex[:8] - timestamp = datetime.now().isoformat() - method = request.method - url = str(request.url) - user_agent = request.headers.get("user-agent", "Unknown") - client_ip = request.client.host if request.client else None - - start_time = perf_counter() - - try: - logger.debug( - f"[{request_id}] Starting: {method} {url}", - extra={"request_id": request_id, "user_agent": user_agent}, - ) - - response: Response = await call_next(request) - process_time = perf_counter() - start_time - - logger.info( - f"[{request_id}] {method} {url} - {response.status_code} - {process_time:.4f}s", - extra={ - "request_id": request_id, - "timestamp": timestamp, - "method": method, - "url": url, - "status": response.status_code, - "process_time": process_time, - "client_ip": client_ip, - "user_agent": user_agent, - }, - ) - return response - - except Exception as e: - process_time = perf_counter() - start_time - logger.error( - f"[{request_id}] {method} {url} - Error: {e} - {process_time:.4f}s", - extra={ - "request_id": request_id, - "timestamp": timestamp, - "method": method, - "url": url, - "error": str(e), - "process_time": process_time, - "client_ip": client_ip, - "user_agent": user_agent, - }, - exc_info=True, - ) - return Response( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content="Internal Server Error", - ) +app.add_middleware(BaseHTTPMiddleware, dispatch=log_request_middleware) # type: ignore[arg-type] +app.add_middleware(BaseHTTPMiddleware, dispatch=catch_exception_middleware) # type: ignore[arg-type] +app.add_exception_handler(status.HTTP_404_NOT_FOUND, not_found_handler) # type: ignore[arg-type] # Подключение маршрутов @@ -193,8 +89,7 @@ if __name__ == "__main__": uvicorn.run( "library_service.main:app", - host="0.0.0.0", - port=8000, + host="0.0.0.0", port=8000, proxy_headers=True, forwarded_allow_ips="*", log_config=LOGGING_CONFIG, diff --git a/library_service/middlewares/__init__.py b/library_service/middlewares/__init__.py new file mode 100644 index 0000000..8a4cbc6 --- /dev/null +++ b/library_service/middlewares/__init__.py @@ -0,0 +1,10 @@ +"""Пакет middleware""" +from .catch_exception import catch_exception_middleware +from .log_request import log_request_middleware +from .not_found_handler import not_found_handler + +__all__ = [ + "catch_exception_middleware", + "log_request_middleware", + "not_found_handler", +] diff --git a/library_service/middlewares/catch_exception.py b/library_service/middlewares/catch_exception.py new file mode 100644 index 0000000..ebd5340 --- /dev/null +++ b/library_service/middlewares/catch_exception.py @@ -0,0 +1,27 @@ +import sys + +from fastapi import Request, Response, status +from starlette.middleware.base import BaseHTTPMiddleware +from fastapi.responses import JSONResponse + +from library_service.settings import get_logger + + +async def catch_exception_middleware(request: Request, call_next): + """Middleware для подробного json-описания Internal error""" + try: + return await call_next(request) + except Exception as exc: + exc_type, exc_value, exc_tb = sys.exc_info() + logger = get_logger() + logger.exception(exc) + + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "message": str(exc), + "type": exc_type.__name__ if exc_type else "Unknown", + "path": str(request.url), + "method": request.method, + }, + ) diff --git a/library_service/middlewares/log_request.py b/library_service/middlewares/log_request.py new file mode 100644 index 0000000..c9c289d --- /dev/null +++ b/library_service/middlewares/log_request.py @@ -0,0 +1,72 @@ +from datetime import datetime +from time import perf_counter +from uuid import uuid4 + +from fastapi import Request, Response, status + +from library_service.settings import get_logger + + +SKIP_LOGGING_PATHS = frozenset({"/favicon.ico", "/favicon.svg"}) + + +async def log_request_middleware(request: Request, call_next): + """Middleware для логирования HTTP-запросов""" + path = request.url.path + if path.startswith("/static") or path in SKIP_LOGGING_PATHS: + return await call_next(request) + + logger = get_logger() + request_id = uuid4().hex[:8] + timestamp = datetime.now().isoformat() + method = request.method + url = str(request.url) + user_agent = request.headers.get("user-agent", "Unknown") + client_ip = request.client.host if request.client else None + + start_time = perf_counter() + + try: + logger.debug( + f"[{request_id}] Starting: {method} {url}", + extra={"request_id": request_id, "user_agent": user_agent}, + ) + + response: Response = await call_next(request) + process_time = perf_counter() - start_time + + logger.info( + f"[{request_id}] {method} {url} - {response.status_code} - {process_time:.4f}s", + extra={ + "request_id": request_id, + "timestamp": timestamp, + "method": method, + "url": url, + "status": response.status_code, + "process_time": process_time, + "client_ip": client_ip, + "user_agent": user_agent, + }, + ) + return response + + except Exception as e: + process_time = perf_counter() - start_time + logger.error( + f"[{request_id}] {method} {url} - Error: {e} - {process_time:.4f}s", + extra={ + "request_id": request_id, + "timestamp": timestamp, + "method": method, + "url": url, + "error": str(e), + "process_time": process_time, + "client_ip": client_ip, + "user_agent": user_agent, + }, + exc_info=True, + ) + return Response( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content="Internal Server Error", + ) diff --git a/library_service/middlewares/not_found_handler.py b/library_service/middlewares/not_found_handler.py new file mode 100644 index 0000000..7df27a8 --- /dev/null +++ b/library_service/middlewares/not_found_handler.py @@ -0,0 +1,24 @@ +from fastapi import Request, Response, status, HTTPException +from fastapi.responses import JSONResponse + +from library_service.settings import get_app +from library_service.routers.misc import unknown + + +async def not_found_handler(request: Request, exc: HTTPException): + """Middleware для обработки 404 ошибки""" + if exc.detail == "Not Found": + path = request.url.path + + if path.startswith("/api/"): + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={"detail": "API endpoint not found", "path": path}, + ) + app = get_app() + return await unknown(request, app) + + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail}, + ) diff --git a/library_service/routers/__init__.py b/library_service/routers/__init__.py index 5756284..cf8fab3 100644 --- a/library_service/routers/__init__.py +++ b/library_service/routers/__init__.py @@ -11,6 +11,7 @@ from .relationships import router as relationships_router from .cap import router as cap_router from .users import router as users_router from .misc import router as misc_router +from .llm import router as llm_router api_router = APIRouter() @@ -26,3 +27,4 @@ api_router.include_router(loans_router, prefix="/api") api_router.include_router(cap_router, prefix="/api") api_router.include_router(users_router, prefix="/api") api_router.include_router(relationships_router, prefix="/api") +api_router.include_router(llm_router, prefix="/api") diff --git a/library_service/routers/llm.py b/library_service/routers/llm.py new file mode 100644 index 0000000..24f8f94 --- /dev/null +++ b/library_service/routers/llm.py @@ -0,0 +1,253 @@ +"""Модуль обработки запросов к llm""" + +import asyncio, json +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status +from ollama import AsyncClient + +from library_service.settings import get_logger, OLLAMA_URL, ASSISTANT_LLM +from library_service.auth import RequireStaffWS + + +logger = get_logger() +router = APIRouter(prefix="/llm") +client = AsyncClient(host=OLLAMA_URL) + + +SYSTEM_PROMPT = """ +Ты — ассистент библиотекаря. Помогаешь заполнять карточку книги. + +Доступные поля: +- title (строка) — название книги +- description (строка) — описание книги +- page_count (целое число ≥ 1 или null) — количество страниц +- status — ТОЛЬКО ДЛЯ ЧТЕНИЯ, изменять ЗАПРЕЩЕНО + +Правила: +1. Используй инструменты для изменения полей книги. +2. Можешь вызывать несколько инструментов за раз. +3. Если пользователь просит изменить status — вежливо откажи. +4. Если page_count задаётся — только целое число ≥ 1. +5. Для очистки поля передавай null. +6. Отвечай кратко и по делу.""" + + +TOOLS = [ + { + "type": "function", + "function": { + "name": "set_title", + "description": "Установить или очистить название книги", + "parameters": { + "type": "object", + "properties": { + "value": { + "type": ["string", "null"], + "description": "Новое название или null для очистки", + } + }, + "required": ["value"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "set_description", + "description": "Установить или очистить описание книги", + "parameters": { + "type": "object", + "properties": { + "value": { + "type": ["string", "null"], + "description": "Новое описание или null для очистки", + } + }, + "required": ["value"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "set_page_count", + "description": "Установить или очистить количество страниц", + "parameters": { + "type": "object", + "properties": { + "value": { + "type": ["integer", "null"], + "minimum": 1, + "description": "Количество страниц (≥1) или null для очистки", + } + }, + "required": ["value"], + }, + }, + }, +] + + +TOOL_TO_TYPE = { + "set_title": "title", + "set_description": "description", + "set_page_count": "page_count", +} + + +@router.websocket("/book") +async def assist_book(websocket: WebSocket, current_user: RequireStaffWS): + """WebSocket-ассистент для заполнения карточки книги. + + IN (от клиента): + { + "prompt": "текст запроса", + "fields": { + "title": "...", + "description": "...", + "page_count": 1, + "status": "..." + } + } + + OUT (от сервера, потоково, много сообщений): + { + "type": "info" | "thinking" | "title" | "description" | "page_count" | "end", + "value": "..." | int | null + } + """ + await websocket.accept() + + messages: list[dict] = [ + {"role": "system", "content": SYSTEM_PROMPT} + ] + + try: + if not ASSISTANT_LLM: + await websocket.close(status.WS_1011_INTERNAL_ERROR, "LLM not available") + return + + while True: + request = await websocket.receive_json() + prompt: str = request["prompt"] + fields: dict = request.get("fields", {}) + + user_content = ( + f"Текущие поля книги:\n" + f"- title: {fields.get('title', '')!r}\n" + f"- description: {fields.get('description', '')!r}\n" + f"- page_count: {fields.get('page_count')!r}\n" + f"- status: {fields.get('status', '-')!r} (read-only)\n\n" + f"Запрос: {prompt}" + ) + messages.append({"role": "user", "content": user_content}) + + await _process_llm_loop(websocket, messages) + + except WebSocketDisconnect: + pass + + +async def _process_llm_loop(websocket: WebSocket, messages: list[dict]): + while True: + assistant_text, tool_calls = await _stream_response(websocket, messages) + + assistant_msg: dict = {"role": "assistant", "content": assistant_text or ""} + if tool_calls: + assistant_msg["tool_calls"] = tool_calls + messages.append(assistant_msg) + + if not tool_calls: + await websocket.send_json({ + "type": "end", + "value": "", + }) + break + + for call in tool_calls: + func_name = call["function"]["name"] + + raw_args = call["function"].get("arguments", {}) + if isinstance(raw_args, str): + try: + args = json.loads(raw_args) + except (json.JSONDecodeError, KeyError): + args = {} + elif isinstance(raw_args, dict): + args = raw_args + else: + args = {} + + value = args.get("value") + msg_type = TOOL_TO_TYPE.get(func_name) + + if msg_type: + if msg_type == "page_count" and value is not None: + if not isinstance(value, int) or value < 1: + value = None + + await websocket.send_json({ + "type": msg_type, + "value": value, + }) + + messages.append({ + "role": "tool", + "content": json.dumps({ + "status": "ok", + "field": msg_type, + "value": value, + }), + }) + else: + messages.append({ + "role": "tool", + "content": json.dumps({ + "status": "error", + "message": f"Unknown tool: {func_name}", + }), + }) + + +async def _stream_response( + websocket: WebSocket, + messages: list[dict], +) -> tuple[str, list[dict]]: + """Стримит ответ модели в WebSocket.""" + full_text = "" + full_thinking = "" + tool_calls: list[dict] = [] + in_thinking = False + + stream = await client.chat( + model=ASSISTANT_LLM, + messages=messages, + tools=TOOLS, + stream=True, + ) + + async for chunk in stream: + message = chunk.get("message", {}) + + thinking_content = message.get("thinking", "") + if thinking_content: + in_thinking = True + full_thinking += thinking_content + await websocket.send_json({ + "type": "thinking", + "value": thinking_content, + }) + + content = message.get("content", "") + if content: + if in_thinking: + in_thinking = False + full_text += content + await websocket.send_json({ + "type": "info", + "value": content, + }) + + if message.get("tool_calls"): + tool_calls.extend(message["tool_calls"]) + + return full_text, tool_calls diff --git a/library_service/routers/misc.py b/library_service/routers/misc.py index 0eac8a5..dd9bb0a 100644 --- a/library_service/routers/misc.py +++ b/library_service/routers/misc.py @@ -84,7 +84,7 @@ async def edit_author(request: Request, author_id: str, app=Depends(lambda: get_ """Рендерит страницу редактирования автора""" try: - author = session.get(Author, int(author_id)) + author = session.get(Author, int(author_id)) # ty: ignore assert author is not None except: return await unknown(request, app) @@ -100,7 +100,7 @@ async def author(request: Request, author_id: str, app=Depends(lambda: get_app() return RedirectResponse("/authors") try: - author = session.get(Author, int(author_id)) + author = session.get(Author, int(author_id)) # ty: ignore assert author is not None except: return await unknown(request, app) @@ -125,7 +125,7 @@ async def edit_book(request: Request, book_id: str, app=Depends(lambda: get_app( """Рендерит страницу редактирования книги""" try: - book = session.get(Book, int(book_id)) + book = session.get(Book, int(book_id)) # ty: ignore assert book is not None except: return await unknown(request, app) @@ -141,7 +141,7 @@ async def book(request: Request, book_id: str, app=Depends(lambda: get_app()), s return RedirectResponse("/books") try: - book = session.get(Book, int(book_id)) + book = session.get(Book, int(book_id)) # ty: ignore assert book is not None except: return await unknown(request, app) @@ -238,9 +238,9 @@ async def api_stats(session=Depends(get_session)): users = select(func.count()).select_from(User) return JSONResponse( content={ - "authors": session.exec(authors).one(), - "books": session.exec(books).one(), - "genres": session.exec(genres).one(), - "users": session.exec(users).one(), + "authors": session.exec(authors).one(), # ty: ignore + "books": session.exec(books).one(), # ty: ignore + "genres": session.exec(genres).one(), # ty: ignore + "users": session.exec(users).one(), # ty: ignore } ) diff --git a/library_service/settings.py b/library_service/settings.py index 62bf046..065f3cf 100644 --- a/library_service/settings.py +++ b/library_service/settings.py @@ -3,6 +3,7 @@ import os, logging from pathlib import Path +import psutil from dotenv import load_dotenv from fastapi import FastAPI from sqlmodel import Session, create_engine @@ -100,6 +101,17 @@ DATABASE = os.getenv("POSTGRES_DB") OLLAMA_URL = os.getenv("OLLAMA_URL") +ASSISTANT_LLM = "" +logger = get_logger() +total_memory_bytes = psutil.virtual_memory().total +total_memory_gb = total_memory_bytes / (1024 ** 3) +if total_memory_gb > 5: + ASSISTANT_LLM = os.getenv("ASSISTANT_LLM", "") + if not ASSISTANT_LLM: + logger.info("[=] Assistant model not set") +else: + logger.info("[=] Not enough RAM for LLM") + if not all([HOST, PORT, USER, PASSWORD, DATABASE, OLLAMA_URL]): raise ValueError("Missing required POSTGRES environment variables") diff --git a/library_service/static/page/create_book.js b/library_service/static/page/create_book.js index 13b87be..6b86a02 100644 --- a/library_service/static/page/create_book.js +++ b/library_service/static/page/create_book.js @@ -10,6 +10,9 @@ $(document).ready(() => { const $form = $("#create-book-form"); const $submitBtn = $("#submit-btn"); const $submitText = $("#submit-text"); + const $titleInput = $("#book-title"); + const $descInput = $("#book-description"); + const $pagesInput = $("#book-page-count"); const $loadingSpinner = $("#loading-spinner"); const $successModal = $("#success-modal"); @@ -20,6 +23,7 @@ $(document).ready(() => { initAuthors(allAuthors); initGenres(allGenres); initializeDropdownListeners(); + initAiAssistant(); }) .catch((err) => { console.error("Ошибка загрузки данных:", err); @@ -350,4 +354,483 @@ $(document).ready(() => { window.location.href = "/books"; } }); + + function initAiAssistant() { + const $aiLogo = $("#ai-logo"); + const $aiWidget = $("#ai-widget"); + const $aiInput = $("#ai-input"); + const $btnRun = $("#ai-btn-run"); + const $btnStop = $("#ai-btn-stop"); + const $logWrap = $("#ai-log-container"); + const $logEntries = $("#ai-log-entries"); + const $dot = $("#ai-status-dot"); + const $ping = $("#ai-status-ping"); + const $statusTxt = $("#ai-status-text"); + + const AI_FIELD_TYPES = ["title", "description", "page_count"]; + const AI_FIELD_LABELS = { + title: "Название", + description: "Описание", + page_count: "Кол-во страниц", + }; + + if ($aiLogo) { + $aiLogo.on("click", () => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.close(); + } + + hideWidget(); + aiRunning = false; + curThinkEl = null; + curInfoEl = null; + lastType = null; + twQueues.clear(); + + aiStatus("ready"); + connectWs(); + }); + } + + let ws = null; + let aiRunning = false; + let curThinkEl = null; + let curInfoEl = null; + let lastType = null; + let idleTimer = null; + + const twQueues = new Map(); + + function twEnqueue(el, text, scrollCb) { + const raw = el instanceof $ ? el[0] : el; + if (!raw) return; + if (!twQueues.has(raw)) { + twQueues.set(raw, { buffer: "", raf: null, scrollCb: null }); + } + const q = twQueues.get(raw); + q.buffer += text; + if (scrollCb) q.scrollCb = scrollCb; + if (!q.raf) twTick(raw); + } + + function twFlush(el) { + const raw = el instanceof $ ? el[0] : el; + if (!raw) return; + const q = twQueues.get(raw); + if (!q) return; + if (q.raf) cancelAnimationFrame(q.raf); + raw.textContent += q.buffer; + q.buffer = ""; + q.raf = null; + twQueues.delete(raw); + } + + function twTick(raw) { + const q = twQueues.get(raw); + if (!q || !q.buffer.length) { + if (q) q.raf = null; + return; + } + const n = Math.max(1, Math.ceil(q.buffer.length * 0.1)); + raw.textContent += q.buffer.substring(0, n); + q.buffer = q.buffer.substring(n); + if (q.scrollCb) q.scrollCb(); + scrollLog(); + if (q.buffer.length) { + q.raf = requestAnimationFrame(() => twTick(raw)); + } else { + q.raf = null; + } + } + + let twRafId = null; + + function startTwTick() { + if (twRafId === null) { + const tick = () => { + twQueues.forEach((_, el) => twTick(el)); + twRafId = requestAnimationFrame(tick); + }; + twRafId = requestAnimationFrame(tick); + } + } + + function stopTwTick() { + if (twRafId !== null) { + cancelAnimationFrame(twRafId); + twRafId = null; + } + } + + function aiStatus(s) { + if (s === "streaming") { + $dot.removeClass("bg-gray-300 hidden").addClass("bg-green-500"); + $ping.removeClass("hidden"); + $statusTxt.text("Пишет…"); + } else if (s === "connected") { + $dot.removeClass("bg-gray-300 hidden").addClass("bg-green-500"); + $ping.addClass("hidden"); + $statusTxt.text("Подключено"); + } else { + $dot.removeClass("bg-green-500").addClass("bg-gray-300 hidden"); + $ping.addClass("hidden"); + $statusTxt.text("Готов"); + } + } + + function setAiRunning(isRunning) { + aiRunning = isRunning; + + $btnRun[0].classList.toggle("hidden", isRunning); + $btnStop[0].classList.toggle("hidden", !isRunning); + + $aiInput.prop("disabled", isRunning); + + aiStatus(isRunning ? "streaming" : "ready"); + } + + function scrollLog() { + const el = $logWrap[0]; + if (el) el.scrollTop = el.scrollHeight; + } + + function resetIdle() { + clearTimeout(idleTimer); + if (aiRunning) { + idleTimer = setTimeout(() => finishResponse(), 1500); + } + } + + function getFields() { + return { + title: $titleInput.val() || null, + description: $descInput.val() || null, + page_count: $pagesInput.val() ? parseInt($pagesInput.val(), 10) : null, + }; + } + + function aiApplyField(type, value) { + if (type === "title") { + $titleInput.val(value === null ? "" : value).trigger("input"); + } else if (type === "description") { + $descInput.val(value === null ? "" : value).trigger("input"); + } else if (type === "page_count") { + $pagesInput.val(value === null ? "" : value).trigger("input"); + } + } + + function addThinkBlock() { + const id = "ai-t-" + Date.now(); + $logEntries.append(` +