Исправление ошибок, добавление ИИ-ассистента

This commit is contained in:
2026-02-16 14:43:14 +03:00
parent 5b7ea9276b
commit 213d2bcb5a
18 changed files with 1672 additions and 426 deletions
+4 -4
View File
@@ -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:
+1
View File
@@ -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
+1
View File
@@ -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
+4
View File
@@ -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",
+43
View File
@@ -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:
+13 -118
View File
@@ -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,
+10
View File
@@ -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",
]
@@ -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,
},
)
@@ -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",
)
@@ -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},
)
+2
View File
@@ -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")
+253
View File
@@ -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
+8 -8
View File
@@ -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
}
)
+12
View File
@@ -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")
+483
View File
@@ -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(`
<div class="border-b border-gray-100 bg-gray-50/50 fade-in" id="${id}">
<div class="px-4 py-2 border-b border-gray-100/50 flex items-center gap-2 cursor-pointer select-none" data-toggle="${id}-b">
<svg class="ai-t-spin animate-spin w-3 h-3 text-gray-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Думает</span>
<svg class="w-3 h-3 text-gray-400 ml-auto transition-transform ai-t-chev" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
<div id="${id}-b" class="px-4 py-3 max-h-40 overflow-y-auto">
<p class="ai-t-txt text-xs font-mono text-gray-500 leading-relaxed whitespace-pre-wrap typing-cursor"></p>
</div>
</div>
`);
$(`[data-toggle="${id}-b"]`).on("click", function () {
$(`#${id}-b`).toggleClass("hidden");
$(this).find(".ai-t-chev").toggleClass("rotate-180");
});
curThinkEl = $(`#${id}`);
scrollLog();
}
function appendThink(text) {
if (!curThinkEl) addThinkBlock();
const p = curThinkEl.find(".ai-t-txt")[0];
const sp = p ? p.parentElement : null;
twEnqueue(p, text, () => {
if (sp) sp.scrollTop = sp.scrollHeight;
});
}
function endThink() {
if (!curThinkEl) return;
const p = curThinkEl.find(".ai-t-txt")[0];
twFlush(p);
curThinkEl.find(".ai-t-txt").removeClass("typing-cursor");
curThinkEl.find(".ai-t-spin").removeClass("animate-spin").html(`
<svg class="w-3 h-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
`);
const id = curThinkEl.attr("id");
if (id) {
$(`#${id}-b`).addClass("hidden");
curThinkEl.find(".ai-t-chev").addClass("rotate-180");
}
curThinkEl = null;
}
function addToolEntry(field, value) {
const label = AI_FIELD_LABELS[field] || field;
const disp =
value === null
? '<span class="text-gray-400 italic">очищено</span>'
: Utils.escapeHtml(String(value));
$logEntries.append(`
<div class="px-4 py-2.5 border-b border-gray-100 flex items-center gap-3 fade-in bg-amber-50/50">
<svg class="w-3.5 h-3.5 text-amber-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
<div class="text-xs text-gray-700">
<span class="font-semibold text-amber-700">${label}</span>
<span class="text-gray-400 mx-1">→</span>
<span class="font-mono">${disp}</span>
</div>
</div>
`);
scrollLog();
}
function addInfoBlock() {
const id = "ai-i-" + Date.now();
$logEntries.append(`
<div class="px-4 py-4 bg-white fade-in" id="${id}">
<div class="flex gap-3">
<div class="mt-0.5 flex-shrink-0">
<svg class="w-4 h-4 text-gray-800" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"></path>
</svg>
</div>
<div class="ai-i-txt text-sm text-gray-800 leading-6 font-medium typing-cursor"></div>
</div>
</div>
`);
curInfoEl = $(`#${id}`);
scrollLog();
}
function appendInfo(text) {
if (!curInfoEl) addInfoBlock();
const el = curInfoEl.find(".ai-i-txt")[0];
twEnqueue(el, text);
}
function endInfo() {
if (!curInfoEl) return;
const el = curInfoEl.find(".ai-i-txt")[0];
twFlush(el);
curInfoEl.find(".ai-i-txt").removeClass("typing-cursor");
curInfoEl = null;
}
function addPromptSep(text) {
$logEntries.append(`
<div class="px-4 py-3 bg-gray-50 border-b border-t border-gray-100 fade-in">
<div class="flex items-center gap-3">
<div class="mt-0.5 flex-shrink-0">
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
</div>
<span class="text-sm text-gray-800 leading-6 font-medium truncate">${Utils.escapeHtml(text)}</span>
</div>
</div>
`);
scrollLog();
}
function hideWidget() {
$aiWidget.addClass("hidden");
$logWrap.addClass("hidden");
$logEntries.empty();
curThinkEl = null;
curInfoEl = null;
lastType = null;
twQueues.forEach((q) => {
if (q.raf) cancelAnimationFrame(q.raf);
});
twQueues.clear();
}
function finishResponse() {
endThink();
endInfo();
setAiRunning(false);
}
function handleMsg(raw) {
let msg;
try {
msg = JSON.parse(raw);
} catch (e) {
return;
}
const { type, value } = msg;
if (type === "end") {
finishResponse();
return;
}
if (type === "thinking") {
if (lastType !== "thinking") {
endThink();
endInfo();
curThinkEl = null;
}
appendThink(value);
lastType = "thinking";
return;
}
if (AI_FIELD_TYPES.includes(type)) {
endThink();
aiApplyField(type, value);
addToolEntry(type, value);
lastType = "tool";
return;
}
if (type === "info") {
if (lastType !== "info") {
endThink();
endInfo();
curInfoEl = null;
}
appendInfo(value);
lastType = "info";
return;
}
}
function connectWs() {
if (
ws &&
(ws.readyState === WebSocket.OPEN ||
ws.readyState === WebSocket.CONNECTING)
)
return;
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const token = StorageHelper.get("access_token");
if (!token) {
Utils.showToast("Вы не авторизованы", "error");
return;
}
ws = new WebSocket(
`${proto}//${window.location.host}/api/llm/book?token=${token}`,
);
ws.onopen = () => {
aiStatus("connected");
$aiWidget.removeClass("hidden");
};
ws.onclose = (e) => {
const wasRunning = aiRunning;
ws = null;
if (wasRunning) {
finishResponse();
}
if (e.code === 1011 || e.code === 1006) {
hideWidget();
} else if (e.code !== 1000 && e.reason) {
Utils.showToast(e.reason, "error");
}
aiStatus("ready");
};
ws.onerror = () => {
Utils.showToast("Ошибка WebSocket соединения", "error");
};
ws.onmessage = (e) => handleMsg(e.data);
}
function doSend(prompt) {
setAiRunning(true);
lastType = null;
curThinkEl = null;
curInfoEl = null;
$logWrap.removeClass("hidden");
addPromptSep(prompt);
try {
ws.send(JSON.stringify({ prompt, fields: getFields() }));
} catch (e) {
console.error("Failed to send AI prompt", e);
finishResponse();
}
$aiInput.val("");
}
function sendPrompt(prompt) {
if (!prompt) {
$aiInput[0].focus();
return;
}
if (!ws || ws.readyState !== WebSocket.OPEN) {
connectWs();
let waited = 0;
const iv = setInterval(() => {
waited += 50;
if (ws && ws.readyState === WebSocket.OPEN) {
clearInterval(iv);
doSend(prompt);
} else if (waited >= 5000) {
clearInterval(iv);
Utils.showToast("Не удалось подключиться", "error");
setAiRunning(false);
}
}, 50);
return;
}
doSend(prompt);
}
$btnRun.on("click", () => {
const p = $aiInput.val().trim();
if (!p) {
$aiInput.addClass("placeholder-red-400");
setTimeout(() => $aiInput.removeClass("placeholder-red-400"), 500);
$aiInput[0].focus();
return;
}
sendPrompt(p);
});
$aiInput.on("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
if (!aiRunning) $btnRun.trigger("click");
}
});
$aiInput.on("input", () => {
$aiInput.removeClass("placeholder-red-400");
});
$btnStop.on("click", () => {
if (ws) {
ws.close();
ws = null;
}
finishResponse();
});
connectWs();
}
});
+483 -1
View File
@@ -59,11 +59,13 @@ $(document).ready(() => {
$form.removeClass("hidden");
$dangerZone.removeClass("hidden");
$("#cancel-btn").attr("href", `/book/${bookId}`);
initAiAssistant();
})
.catch((error) => {
console.error(error);
Utils.showToast("Ошибка загрузки данных", "error");
setTimeout(() => (window.location.href = "/books"), 1500);
// setTimeout(() => (window.location.href = "/books"), 1500);
});
function populateForm(book) {
@@ -457,4 +459,484 @@ $(document).ready(() => {
}
}
});
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,
status: $statusSelect.val() || "active",
};
}
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(`
<div class="border-b border-gray-100 bg-gray-50/50 fade-in" id="${id}">
<div class="px-4 py-2 border-b border-gray-100/50 flex items-center gap-2 cursor-pointer select-none" data-toggle="${id}-b">
<svg class="ai-t-spin animate-spin w-3 h-3 text-gray-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Думает</span>
<svg class="w-3 h-3 text-gray-400 ml-auto transition-transform ai-t-chev" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
<div id="${id}-b" class="px-4 py-3 max-h-40 overflow-y-auto">
<p class="ai-t-txt text-xs font-mono text-gray-500 leading-relaxed whitespace-pre-wrap typing-cursor"></p>
</div>
</div>
`);
$(`[data-toggle="${id}-b"]`).on("click", function () {
$(`#${id}-b`).toggleClass("hidden");
$(this).find(".ai-t-chev").toggleClass("rotate-180");
});
curThinkEl = $(`#${id}`);
scrollLog();
}
function appendThink(text) {
if (!curThinkEl) addThinkBlock();
const p = curThinkEl.find(".ai-t-txt")[0];
const sp = p ? p.parentElement : null;
twEnqueue(p, text, () => {
if (sp) sp.scrollTop = sp.scrollHeight;
});
}
function endThink() {
if (!curThinkEl) return;
const p = curThinkEl.find(".ai-t-txt")[0];
twFlush(p);
curThinkEl.find(".ai-t-txt").removeClass("typing-cursor");
curThinkEl.find(".ai-t-spin").removeClass("animate-spin").html(`
<svg class="w-3 h-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
`);
const id = curThinkEl.attr("id");
if (id) {
$(`#${id}-b`).addClass("hidden");
curThinkEl.find(".ai-t-chev").addClass("rotate-180");
}
curThinkEl = null;
}
function addToolEntry(field, value) {
const label = AI_FIELD_LABELS[field] || field;
const disp =
value === null
? '<span class="text-gray-400 italic">очищено</span>'
: Utils.escapeHtml(String(value));
$logEntries.append(`
<div class="px-4 py-2.5 border-b border-gray-100 flex items-center gap-3 fade-in bg-amber-50/50">
<svg class="w-3.5 h-3.5 text-amber-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
<div class="text-xs text-gray-700">
<span class="font-semibold text-amber-700">${label}</span>
<span class="text-gray-400 mx-1">→</span>
<span class="font-mono">${disp}</span>
</div>
</div>
`);
scrollLog();
}
function addInfoBlock() {
const id = "ai-i-" + Date.now();
$logEntries.append(`
<div class="px-4 py-4 bg-white fade-in" id="${id}">
<div class="flex gap-3">
<div class="mt-0.5 flex-shrink-0">
<svg class="w-4 h-4 text-gray-800" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"></path>
</svg>
</div>
<div class="ai-i-txt text-sm text-gray-800 leading-6 font-medium typing-cursor"></div>
</div>
</div>
`);
curInfoEl = $(`#${id}`);
scrollLog();
}
function appendInfo(text) {
if (!curInfoEl) addInfoBlock();
const el = curInfoEl.find(".ai-i-txt")[0];
twEnqueue(el, text);
}
function endInfo() {
if (!curInfoEl) return;
const el = curInfoEl.find(".ai-i-txt")[0];
twFlush(el);
curInfoEl.find(".ai-i-txt").removeClass("typing-cursor");
curInfoEl = null;
}
function addPromptSep(text) {
$logEntries.append(`
<div class="px-4 py-3 bg-gray-50 border-b border-t border-gray-100 fade-in">
<div class="flex items-center gap-3">
<div class="mt-0.5 flex-shrink-0">
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
</div>
<span class="text-sm text-gray-800 leading-6 font-medium truncate">${Utils.escapeHtml(text)}</span>
</div>
</div>
`);
scrollLog();
}
function hideWidget() {
$aiWidget.addClass("hidden");
$logWrap.addClass("hidden");
$logEntries.empty();
curThinkEl = null;
curInfoEl = null;
lastType = null;
twQueues.forEach((q) => {
if (q.raf) cancelAnimationFrame(q.raf);
});
twQueues.clear();
}
function finishResponse() {
endThink();
endInfo();
setAiRunning(false);
}
function handleMsg(raw) {
let msg;
try {
msg = JSON.parse(raw);
} catch (e) {
return;
}
const { type, value } = msg;
if (type === "end") {
finishResponse();
return;
}
if (type === "thinking") {
if (lastType !== "thinking") {
endThink();
endInfo();
curThinkEl = null;
}
appendThink(value);
lastType = "thinking";
return;
}
if (AI_FIELD_TYPES.includes(type)) {
endThink();
aiApplyField(type, value);
addToolEntry(type, value);
lastType = "tool";
return;
}
if (type === "info") {
if (lastType !== "info") {
endThink();
endInfo();
curInfoEl = null;
}
appendInfo(value);
lastType = "info";
return;
}
}
function connectWs() {
if (
ws &&
(ws.readyState === WebSocket.OPEN ||
ws.readyState === WebSocket.CONNECTING)
)
return;
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const token = StorageHelper.get("access_token");
if (!token) {
Utils.showToast("Вы не авторизованы", "error");
return;
}
ws = new WebSocket(
`${proto}//${window.location.host}/api/llm/book?token=${token}`,
);
ws.onopen = () => {
aiStatus("connected");
$aiWidget.removeClass("hidden");
};
ws.onclose = (e) => {
const wasRunning = aiRunning;
ws = null;
if (wasRunning) {
finishResponse();
}
if (e.code === 1011 || e.code === 1006) {
hideWidget();
} else if (e.code !== 1000 && e.reason) {
Utils.showToast(e.reason, "error");
}
aiStatus("ready");
};
ws.onerror = () => {
Utils.showToast("Ошибка WebSocket соединения", "error");
};
ws.onmessage = (e) => handleMsg(e.data);
}
function doSend(prompt) {
setAiRunning(true);
lastType = null;
curThinkEl = null;
curInfoEl = null;
$logWrap.removeClass("hidden");
addPromptSep(prompt);
try {
ws.send(JSON.stringify({ prompt, fields: getFields() }));
} catch (e) {
console.error("Failed to send AI prompt", e);
finishResponse();
}
$aiInput.val("");
}
function sendPrompt(prompt) {
if (!prompt) {
$aiInput[0].focus();
return;
}
if (!ws || ws.readyState !== WebSocket.OPEN) {
connectWs();
let waited = 0;
const iv = setInterval(() => {
waited += 50;
if (ws && ws.readyState === WebSocket.OPEN) {
clearInterval(iv);
doSend(prompt);
} else if (waited >= 5000) {
clearInterval(iv);
Utils.showToast("Не удалось подключиться", "error");
setAiRunning(false);
}
}, 50);
return;
}
doSend(prompt);
}
$btnRun.on("click", () => {
const p = $aiInput.val().trim();
if (!p) {
$aiInput.addClass("placeholder-red-400");
setTimeout(() => $aiInput.removeClass("placeholder-red-400"), 500);
$aiInput[0].focus();
return;
}
sendPrompt(p);
});
$aiInput.on("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
if (!aiRunning) $btnRun.trigger("click");
}
});
$aiInput.on("input", () => {
$aiInput.removeClass("placeholder-red-400");
});
$btnStop.on("click", () => {
if (ws) {
ws.close();
ws = null;
}
finishResponse();
});
connectWs();
}
});
@@ -1,4 +1,25 @@
{% extends "base.html" %}{% block content %}
<style>
.typing-cursor::after {
content: '▋';
display: inline-block;
vertical-align: bottom;
animation: blink 1s step-end infinite;
margin-left: 2px;
opacity: 0.7;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.fade-in {
animation: fadeIn 0.3s ease-in-out forwards;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
<div class="container mx-auto p-4 max-w-3xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4">
@@ -25,6 +46,64 @@
</p>
</div>
<form id="create-book-form" class="space-y-6">
<div id="ai-widget" class="hidden border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm transition-shadow hover:shadow-md">
<div class="bg-gray-50 border-b border-gray-200 px-4 py-2.5 flex justify-between items-center select-none">
<div class="flex items-center gap-2.5">
<div id="ai-logo" class="cursor-pointer p-1 bg-white border border-gray-200 rounded shadow-sm">
<svg class="w-5 h-5 text-gray-800" fill="currentColor" viewBox="0 0 24 24">
<rect x="2" y="19" width="20" height="3" rx="0.8" fill="currentColor" opacity="0.9"/>
<path d="M12 17V7c0 0-2.5-2-6-2-1.8 0-3 .4-3.5.6V17c.8-.3 2-.5 3.5-.5 3.2 0 6 1.5 6 1.5z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
<path d="M12 17V7c0 0 2.5-2 6-2 1.8 0 3 .4 3.5.6V17c-.8-.3-2-.5-3.5-.5-3.2 0-6 1.5-6 1.5z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
<circle cx="12" cy="2.8" r="2" fill="currentColor"/>
<circle cx="7.5" cy="3.8" r="1" fill="currentColor"/>
<circle cx="16.5" cy="3.8" r="1" fill="currentColor"/>
<line x1="10.2" y1="3" x2="8.4" y2="3.6" stroke="currentColor" stroke-width="1"/>
<line x1="13.8" y1="3" x2="15.6" y2="3.6" stroke="currentColor" stroke-width="1"/>
</svg>
</div>
<span class="text-xs font-bold text-gray-700 tracking-[0.15em] uppercase">ИИ-помощник</span>
</div>
<div class="flex items-center gap-2">
<span class="text-[10px] font-mono uppercase text-gray-400" id="ai-status-text">Готов</span>
<span class="relative flex h-2 w-2">
<span id="ai-status-ping" class="hidden animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span id="ai-status-dot" class="relative inline-flex rounded-full h-2 w-2 bg-gray-300 transition-colors duration-300"></span>
</span>
</div>
</div>
<div id="ai-log-container" class="hidden border-b border-gray-100 bg-white max-h-80 overflow-y-auto">
<div id="ai-log-entries"></div>
</div>
<div class="flex relative bg-white">
<input
type="text"
id="ai-input"
class="flex-1 px-4 py-3.5 text-sm text-gray-900 placeholder-gray-400 bg-transparent focus:outline-none focus:bg-gray-50 transition-colors"
placeholder="Напишите задачу (например: 'Придумай драматичное описание')..."
autocomplete="off"
/>
<div class="flex border-l border-gray-100 items-center">
<button type="button" id="ai-btn-stop"
class="hidden px-5 py-2 h-full bg-white text-red-600 text-xs font-bold tracking-widest hover:bg-red-50 transition-colors flex items-center gap-2">
<span>СТОП</span>
<span class="block w-2 h-2 bg-red-600 rounded-sm"></span>
</button>
<button type="button" id="ai-btn-run"
class="group px-6 py-2 h-full bg-white text-gray-900 text-xs font-bold tracking-widest hover:bg-gray-900 hover:text-white transition-all duration-300 flex items-center gap-2">
<span>СТАРТ</span>
<svg class="w-3 h-3 transition-transform group-hover:translate-x-0.5"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"></path>
</svg>
</button>
</div>
</div>
</div>
<div>
<label
for="book-title"
+153 -295
View File
@@ -1,28 +1,35 @@
{% extends "base.html" %}{% block content %}
<style>
.typing-cursor::after {
content: '▋';
display: inline-block;
vertical-align: bottom;
animation: blink 1s step-end infinite;
margin-left: 2px;
opacity: 0.7;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.fade-in {
animation: fadeIn 0.3s ease-in-out forwards;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
<div class="container mx-auto p-4 max-w-3xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4">
<h1
class="text-2xl font-bold text-gray-800 flex items-center gap-3"
>
<svg
class="w-8 h-8 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
></path>
<h1 class="text-2xl font-bold text-gray-800 flex items-center gap-3">
<svg class="w-8 h-8 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
</svg>
<span>Редактирование книги</span>
</h1>
<p class="text-gray-500 mt-2 text-sm ml-11">
Измените информацию о книге, управляйте авторами и жанрами.
</p>
<p class="text-gray-500 mt-2 text-sm ml-11">Измените информацию о книге, управляйте авторами и жанрами.</p>
</div>
<div id="loader" class="animate-pulse space-y-4">
@@ -32,80 +39,97 @@
</div>
<form id="edit-book-form" class="hidden space-y-6">
<div id="ai-widget" class="hidden border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm transition-shadow hover:shadow-md">
<div class="bg-gray-50 border-b border-gray-200 px-4 py-2.5 flex justify-between items-center select-none">
<div class="flex items-center gap-2.5">
<div id="ai-logo" class="cursor-pointer p-1 bg-white border border-gray-200 rounded shadow-sm">
<svg class="w-5 h-5 text-gray-800" fill="currentColor" viewBox="0 0 24 24">
<rect x="2" y="19" width="20" height="3" rx="0.8" fill="currentColor" opacity="0.9"/>
<path d="M12 17V7c0 0-2.5-2-6-2-1.8 0-3 .4-3.5.6V17c.8-.3 2-.5 3.5-.5 3.2 0 6 1.5 6 1.5z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
<path d="M12 17V7c0 0 2.5-2 6-2 1.8 0 3 .4 3.5.6V17c-.8-.3-2-.5-3.5-.5-3.2 0-6 1.5-6 1.5z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
<circle cx="12" cy="2.8" r="2" fill="currentColor"/>
<circle cx="7.5" cy="3.8" r="1" fill="currentColor"/>
<circle cx="16.5" cy="3.8" r="1" fill="currentColor"/>
<line x1="10.2" y1="3" x2="8.4" y2="3.6" stroke="currentColor" stroke-width="1"/>
<line x1="13.8" y1="3" x2="15.6" y2="3.6" stroke="currentColor" stroke-width="1"/>
</svg>
</div>
<span class="text-xs font-bold text-gray-700 tracking-[0.15em] uppercase">ИИ-помощник</span>
</div>
<div class="flex items-center gap-2">
<span class="text-[10px] font-mono uppercase text-gray-400" id="ai-status-text">Готов</span>
<span class="relative flex h-2 w-2">
<span id="ai-status-ping" class="hidden animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span id="ai-status-dot" class="relative inline-flex rounded-full h-2 w-2 bg-gray-300 transition-colors duration-300"></span>
</span>
</div>
</div>
<div id="ai-log-container" class="hidden border-b border-gray-100 bg-white max-h-80 overflow-y-auto">
<div id="ai-log-entries"></div>
</div>
<div class="flex relative bg-white">
<input
type="text"
id="ai-input"
class="flex-1 px-4 py-3.5 text-sm text-gray-900 placeholder-gray-400 bg-transparent focus:outline-none focus:bg-gray-50 transition-colors"
placeholder="Напишите задачу (например: 'Придумай драматичное описание')..."
autocomplete="off"
/>
<div class="flex border-l border-gray-100 items-center">
<button type="button" id="ai-btn-stop"
class="hidden px-5 py-2 h-full bg-white text-red-600 text-xs font-bold tracking-widest hover:bg-red-50 transition-colors flex items-center gap-2">
<span>СТОП</span>
<span class="block w-2 h-2 bg-red-600 rounded-sm"></span>
</button>
<button type="button" id="ai-btn-run"
class="group px-6 py-2 h-full bg-white text-gray-900 text-xs font-bold tracking-widest hover:bg-gray-900 hover:text-white transition-all duration-300 flex items-center gap-2">
<span>СТАРТ</span>
<svg class="w-3 h-3 transition-transform group-hover:translate-x-0.5"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"></path>
</svg>
</button>
</div>
</div>
</div>
<div>
<label
for="book-title"
class="block text-sm font-semibold text-gray-700 mb-2"
>
<label for="book-title" class="block text-sm font-semibold text-gray-700 mb-2">
Название книги <span class="text-red-500">*</span>
</label>
<input
type="text"
id="book-title"
name="title"
required
maxlength="255"
<input type="text" id="book-title" name="title" required maxlength="255"
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
placeholder="Название книги..."
/>
placeholder="Название книги..." />
<div class="flex justify-end mt-1">
<span id="title-counter" class="text-xs text-gray-400"
>0/255</span
>
<span id="title-counter" class="text-xs text-gray-400">0/255</span>
</div>
</div>
<div>
<label
for="book-description"
class="block text-sm font-semibold text-gray-700 mb-2"
>
Описание
</label>
<textarea
id="book-description"
name="description"
rows="5"
maxlength="2000"
<label for="book-description" class="block text-sm font-semibold text-gray-700 mb-2">Описание</label>
<textarea id="book-description" name="description" rows="5" maxlength="2000"
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 resize-none transition"
placeholder="Краткое описание сюжета..."
></textarea>
placeholder="Краткое описание сюжета..."></textarea>
<div class="flex justify-end mt-1">
<span id="desc-counter" class="text-xs text-gray-400"
>0/2000</span
>
<span id="desc-counter" class="text-xs text-gray-400">0/2000</span>
</div>
</div>
<div>
<label
for="book-page-count"
class="block text-sm font-semibold text-gray-700 mb-2"
>
Количество страниц
</label>
<input
type="number"
id="book-page-count"
name="page_count"
min="1"
<label for="book-page-count" class="block text-sm font-semibold text-gray-700 mb-2">Количество страниц</label>
<input type="number" id="book-page-count" name="page_count" min="1"
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
placeholder="Укажите количество страниц"
/>
placeholder="Укажите количество страниц" />
</div>
<div>
<label
for="book-status"
class="block text-sm font-semibold text-gray-700 mb-2"
>
Статус
</label>
<select
id="book-status"
name="status"
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition bg-white"
>
<label for="book-status" class="block text-sm font-semibold text-gray-700 mb-2">Статус</label>
<select id="book-status" name="status"
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition bg-white">
<option value="active">Доступна</option>
<option value="borrowed">Выдана</option>
<option value="reserved">Забронирована</option>
@@ -116,156 +140,62 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="bg-white p-4 rounded-lg border border-gray-200">
<h2
class="text-sm font-semibold text-gray-700 mb-3 flex items-center justify-between"
>
<h2 class="text-sm font-semibold text-gray-700 mb-3 flex items-center justify-between">
<span>Авторы</span>
<span
id="authors-count"
class="text-xs text-gray-400 font-normal"
></span>
<span id="authors-count" class="text-xs text-gray-400 font-normal"></span>
</h2>
<div
id="current-authors-container"
class="flex flex-wrap gap-2 mb-3 min-h-[32px]"
></div>
<div id="current-authors-container" class="flex flex-wrap gap-2 mb-3 min-h-[32px]"></div>
<div class="relative">
<input
type="text"
id="author-search-input"
placeholder="Добавить автора..."
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400"
autocomplete="off"
/>
<div
id="author-dropdown"
class="hidden absolute z-50 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto mt-1"
></div>
<input type="text" id="author-search-input" placeholder="Добавить автора..."
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400" autocomplete="off" />
<div id="author-dropdown" class="hidden absolute z-50 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto mt-1"></div>
</div>
</div>
<div class="bg-white p-4 rounded-lg border border-gray-200">
<h2
class="text-sm font-semibold text-gray-700 mb-3 flex items-center justify-between"
>
<h2 class="text-sm font-semibold text-gray-700 mb-3 flex items-center justify-between">
<span>Жанры</span>
<span
id="genres-count"
class="text-xs text-gray-400 font-normal"
></span>
<span id="genres-count" class="text-xs text-gray-400 font-normal"></span>
</h2>
<div
id="current-genres-container"
class="flex flex-wrap gap-2 mb-3 min-h-[32px]"
></div>
<div id="current-genres-container" class="flex flex-wrap gap-2 mb-3 min-h-[32px]"></div>
<div class="relative">
<input
type="text"
id="genre-search-input"
placeholder="Добавить жанр..."
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400"
autocomplete="off"
/>
<div
id="genre-dropdown"
class="hidden absolute z-50 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto mt-1"
></div>
<input type="text" id="genre-search-input" placeholder="Добавить жанр..."
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400" autocomplete="off" />
<div id="genre-dropdown" class="hidden absolute z-50 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto mt-1"></div>
</div>
</div>
</div>
<div
class="flex flex-col sm:flex-row gap-3 pt-6 border-t border-gray-100"
>
<button
type="submit"
id="submit-btn"
class="flex-1 flex justify-center items-center px-6 py-3 bg-green-600 text-white font-bold rounded-lg hover:bg-green-700 focus:outline-none focus:ring-4 focus:ring-green-300 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
<div class="flex flex-col sm:flex-row gap-3 pt-6 border-t border-gray-100">
<button type="submit" id="submit-btn"
class="flex-1 flex justify-center items-center px-6 py-3 bg-green-600 text-white font-bold rounded-lg hover:bg-green-700 focus:outline-none focus:ring-4 focus:ring-green-300 transition disabled:opacity-50 disabled:cursor-not-allowed">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<span id="submit-text">Сохранить изменения</span>
<svg
id="loading-spinner"
class="hidden animate-spin ml-2 h-5 w-5 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
<svg id="loading-spinner" class="hidden animate-spin ml-2 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</button>
<a
id="cancel-btn"
href="#"
class="flex-1 flex justify-center items-center px-6 py-3 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition text-center"
>
<a id="cancel-btn" href="#"
class="flex-1 flex justify-center items-center px-6 py-3 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition text-center">
Отмена
</a>
</div>
</form>
<div id="danger-zone" class="hidden mt-8 pt-6 border-t border-red-200">
<h3
class="text-lg font-bold text-red-600 mb-2 flex items-center gap-2"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
></path>
<h3 class="text-lg font-bold text-red-600 mb-2 flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
Опасная зона
</h3>
<p class="text-sm text-gray-600 mb-4">
Удаление книги необратимо. Все связи с авторами и жанрами будут
удалены.
</p>
<button
id="delete-btn"
class="inline-flex items-center px-4 py-2 bg-white border border-red-300 text-red-600 font-medium rounded-lg hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-300 transition"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
></path>
<p class="text-sm text-gray-600 mb-4">Удаление книги необратимо. Все связи с авторами и жанрами будут удалены.</p>
<button id="delete-btn"
class="inline-flex items-center px-4 py-2 bg-white border border-red-300 text-red-600 font-medium rounded-lg hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-300 transition">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
Удалить книгу
</button>
@@ -273,75 +203,32 @@
</div>
</div>
<div
id="delete-modal"
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
>
<div
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
>
<div id="delete-modal" class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center">
<div class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white">
<div class="mt-3 text-center">
<div
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4"
>
<svg
class="h-6 w-6 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
></path>
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
<svg class="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
Удалить книгу?
</h3>
<h3 class="text-lg leading-6 font-medium text-gray-900">Удалить книгу?</h3>
<div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500">
Вы уверены, что хотите удалить книгу
<span
id="modal-book-title"
class="font-bold text-gray-800"
></span
>? Это действие нельзя отменить.
<span id="modal-book-title" class="font-bold text-gray-800"></span>? Это действие нельзя отменить.
</p>
</div>
<div class="flex gap-3 mt-4 justify-center">
<button
id="confirm-delete-btn"
class="px-4 py-2 bg-red-600 text-white text-base font-medium rounded-lg hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 flex items-center"
>
<button id="confirm-delete-btn"
class="px-4 py-2 bg-red-600 text-white text-base font-medium rounded-lg hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 flex items-center">
<span>Удалить</span>
<svg
id="delete-spinner"
class="hidden animate-spin ml-2 h-4 w-4 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
<svg id="delete-spinner" class="hidden animate-spin ml-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</button>
<button
id="cancel-delete-btn"
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
>
<button id="cancel-delete-btn"
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200">
Отмена
</button>
</div>
@@ -349,62 +236,33 @@
</div>
</div>
<div
id="success-modal"
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
>
<div
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
>
<div id="success-modal" class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center">
<div class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white">
<div class="mt-3 text-center">
<div
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4"
>
<svg
class="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4">
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
Изменения сохранены!
</h3>
<h3 class="text-lg leading-6 font-medium text-gray-900">Изменения сохранены!</h3>
<div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500">
Книга
<span
id="success-book-title"
class="font-bold text-gray-800"
></span>
успешно обновлена.
Книга <span id="success-book-title" class="font-bold text-gray-800"></span> успешно обновлена.
</p>
</div>
<div class="flex gap-3 mt-4 justify-center">
<a
id="success-link-btn"
href="#"
class="px-4 py-2 bg-gray-600 text-white text-base font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
>
<a id="success-link-btn" href="#"
class="px-4 py-2 bg-gray-600 text-white text-base font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500">
Перейти к книге
</a>
<button
id="success-close-btn"
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
>
<button id="success-close-btn"
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200">
Продолжить
</button>
</div>
</div>
</div>
</div>
{% endblock %} {% block scripts %}
{% endblock %}{% block scripts %}
<script src="/static/page/edit_book.js"></script>
{% endblock %}