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

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 - ./data/db:/var/lib/postgresql/data
networks: networks:
- proxy - proxy
#ports: # !только локальный тест! # ports: # !только локальный тест!
# - 5432:5432 # - 5432:5432
env_file: env_file:
- ./.env - ./.env
healthcheck: healthcheck:
@@ -33,8 +33,8 @@ services:
- ./data/llm:/root/.ollama - ./data/llm:/root/.ollama
networks: networks:
- proxy - proxy
#ports: # !только локальный тест! # ports: # !только локальный тест!
# - 11434:11434 # - 11434:11434
env_file: env_file:
- ./.env - ./.env
healthcheck: healthcheck:
+1
View File
@@ -6,6 +6,7 @@ POSTGRES_PASSWORD=postgres
POSTGRES_DB=lib POSTGRES_DB=lib
# Ollama # Ollama
ASSISTANT_LLM="qwen3:4b"
OLLAMA_URL="http://llm:11434" OLLAMA_URL="http://llm:11434"
OLLAMA_MAX_LOADED_MODELS=2 OLLAMA_MAX_LOADED_MODELS=2
OLLAMA_NUM_THREADS=4 OLLAMA_NUM_THREADS=4
+1
View File
@@ -6,6 +6,7 @@ POSTGRES_PASSWORD=postgres
POSTGRES_DB=lib POSTGRES_DB=lib
# Ollama # Ollama
ASSISTANT_LLM="qwen3:4b"
OLLAMA_URL="http://localhost:11434" OLLAMA_URL="http://localhost:11434"
OLLAMA_MAX_LOADED_MODELS=2 OLLAMA_MAX_LOADED_MODELS=2
OLLAMA_NUM_THREADS=4 OLLAMA_NUM_THREADS=4
+4
View File
@@ -37,11 +37,13 @@ from .core import (
is_user_admin, is_user_admin,
OptionalAuth, OptionalAuth,
RequireAuth, RequireAuth,
RequireAuthWS,
RequireAdmin, RequireAdmin,
RequireMember, RequireMember,
RequireLibrarian, RequireLibrarian,
RequirePartialAuth, RequirePartialAuth,
RequireStaff, RequireStaff,
RequireStaffWS,
) )
from .seed import ( from .seed import (
@@ -100,10 +102,12 @@ __all__ = [
"is_user_admin", "is_user_admin",
"OptionalAuth", "OptionalAuth",
"RequireAuth", "RequireAuth",
"RequireAuthWS",
"RequireAdmin", "RequireAdmin",
"RequireMember", "RequireMember",
"RequireLibrarian", "RequireLibrarian",
"RequireStaff", "RequireStaff",
"RequireStaffWS",
"seed_roles", "seed_roles",
"seed_admin", "seed_admin",
"run_seeds", "run_seeds",
+43
View File
@@ -8,6 +8,7 @@ import os
from argon2.low_level import hash_secret_raw, Type from argon2.low_level import hash_secret_raw, Type
from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from fastapi import WebSocket, WebSocketException, Query
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError, ExpiredSignatureError from jose import jwt, JWTError, ExpiredSignatureError
@@ -251,6 +252,31 @@ def get_current_user(
return 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( def get_current_active_user(
current_user: Annotated[User, Depends(get_current_user)], current_user: Annotated[User, Depends(get_current_user)],
) -> User: ) -> User:
@@ -315,14 +341,31 @@ def require_any_role(allowed_roles: list[str]):
return role_checker 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 # Создание dependencies
OptionalAuth = Annotated[User | None, Depends(get_optional_user)] OptionalAuth = Annotated[User | None, Depends(get_optional_user)]
RequireAuth = Annotated[User, Depends(get_current_active_user)] RequireAuth = Annotated[User, Depends(get_current_active_user)]
RequireAuthWS = Annotated[User, Depends(get_current_user_ws)]
RequireAdmin = Annotated[User, Depends(require_role("admin"))] RequireAdmin = Annotated[User, Depends(require_role("admin"))]
RequireMember = Annotated[User, Depends(require_role("member"))] RequireMember = Annotated[User, Depends(require_role("member"))]
RequireLibrarian = Annotated[User, Depends(require_role("librarian"))] RequireLibrarian = Annotated[User, Depends(require_role("librarian"))]
RequirePartialAuth = Annotated[User, Depends(get_user_from_partial_token)] RequirePartialAuth = Annotated[User, Depends(get_user_from_partial_token)]
RequireStaff = Annotated[User, Depends(require_any_role(["admin", "librarian"]))] 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: 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 contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path from pathlib import Path
from time import perf_counter
from uuid import uuid4
from alembic import command from alembic import command
from alembic.config import Config 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.staticfiles import StaticFiles
from fastapi.responses import JSONResponse
from ollama import Client, ResponseError from ollama import Client, ResponseError
from sqlmodel import Session from sqlmodel import Session
from library_service.auth import run_seeds from library_service.auth import run_seeds
from library_service.routers import api_router 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.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 ( from library_service.settings import (
LOGGING_CONFIG, LOGGING_CONFIG,
engine, engine,
get_app, get_app,
get_logger, get_logger,
OLLAMA_URL, OLLAMA_URL,
ASSISTANT_LLM,
) )
SKIP_LOGGING_PATHS = frozenset({"/favicon.ico", "/favicon.svg"})
@asynccontextmanager @asynccontextmanager
async def lifespan(_): async def lifespan(_):
"""Жизненный цикл сервиса""" """Жизненный цикл сервиса"""
@@ -60,10 +55,10 @@ async def lifespan(_):
ollama_client = Client(host=OLLAMA_URL) ollama_client = Client(host=OLLAMA_URL)
ollama_client.pull("mxbai-embed-large") ollama_client.pull("mxbai-embed-large")
total_memory_bytes = psutil.virtual_memory().total if ASSISTANT_LLM:
total_memory_gb = total_memory_bytes / (1024 ** 3) ollama_client.pull(ASSISTANT_LLM)
if total_memory_gb > 5: else:
ollama_client.pull("qwen3:4b") logger.info("[=] AI-assistant is not available")
except ResponseError as e: except ResponseError as e:
logger.error(f"[-] Failed to pull models {e}") logger.error(f"[-] Failed to pull models {e}")
@@ -75,108 +70,9 @@ async def lifespan(_):
app = get_app(lifespan) app = get_app(lifespan)
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.exception_handler(status.HTTP_404_NOT_FOUND) app.add_exception_handler(status.HTTP_404_NOT_FOUND, not_found_handler) # type: ignore[arg-type]
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",
)
# Подключение маршрутов # Подключение маршрутов
@@ -193,8 +89,7 @@ if __name__ == "__main__":
uvicorn.run( uvicorn.run(
"library_service.main:app", "library_service.main:app",
host="0.0.0.0", host="0.0.0.0", port=8000,
port=8000,
proxy_headers=True, proxy_headers=True,
forwarded_allow_ips="*", forwarded_allow_ips="*",
log_config=LOGGING_CONFIG, 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 .cap import router as cap_router
from .users import router as users_router from .users import router as users_router
from .misc import router as misc_router from .misc import router as misc_router
from .llm import router as llm_router
api_router = APIRouter() 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(cap_router, prefix="/api")
api_router.include_router(users_router, prefix="/api") api_router.include_router(users_router, prefix="/api")
api_router.include_router(relationships_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: try:
author = session.get(Author, int(author_id)) author = session.get(Author, int(author_id)) # ty: ignore
assert author is not None assert author is not None
except: except:
return await unknown(request, app) 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") return RedirectResponse("/authors")
try: try:
author = session.get(Author, int(author_id)) author = session.get(Author, int(author_id)) # ty: ignore
assert author is not None assert author is not None
except: except:
return await unknown(request, app) return await unknown(request, app)
@@ -125,7 +125,7 @@ async def edit_book(request: Request, book_id: str, app=Depends(lambda: get_app(
"""Рендерит страницу редактирования книги""" """Рендерит страницу редактирования книги"""
try: try:
book = session.get(Book, int(book_id)) book = session.get(Book, int(book_id)) # ty: ignore
assert book is not None assert book is not None
except: except:
return await unknown(request, app) 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") return RedirectResponse("/books")
try: try:
book = session.get(Book, int(book_id)) book = session.get(Book, int(book_id)) # ty: ignore
assert book is not None assert book is not None
except: except:
return await unknown(request, app) return await unknown(request, app)
@@ -238,9 +238,9 @@ async def api_stats(session=Depends(get_session)):
users = select(func.count()).select_from(User) users = select(func.count()).select_from(User)
return JSONResponse( return JSONResponse(
content={ content={
"authors": session.exec(authors).one(), "authors": session.exec(authors).one(), # ty: ignore
"books": session.exec(books).one(), "books": session.exec(books).one(), # ty: ignore
"genres": session.exec(genres).one(), "genres": session.exec(genres).one(), # ty: ignore
"users": session.exec(users).one(), "users": session.exec(users).one(), # ty: ignore
} }
) )
+12
View File
@@ -3,6 +3,7 @@
import os, logging import os, logging
from pathlib import Path from pathlib import Path
import psutil
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import FastAPI from fastapi import FastAPI
from sqlmodel import Session, create_engine from sqlmodel import Session, create_engine
@@ -100,6 +101,17 @@ DATABASE = os.getenv("POSTGRES_DB")
OLLAMA_URL = os.getenv("OLLAMA_URL") 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]): if not all([HOST, PORT, USER, PASSWORD, DATABASE, OLLAMA_URL]):
raise ValueError("Missing required POSTGRES environment variables") raise ValueError("Missing required POSTGRES environment variables")
+483
View File
@@ -10,6 +10,9 @@ $(document).ready(() => {
const $form = $("#create-book-form"); const $form = $("#create-book-form");
const $submitBtn = $("#submit-btn"); const $submitBtn = $("#submit-btn");
const $submitText = $("#submit-text"); const $submitText = $("#submit-text");
const $titleInput = $("#book-title");
const $descInput = $("#book-description");
const $pagesInput = $("#book-page-count");
const $loadingSpinner = $("#loading-spinner"); const $loadingSpinner = $("#loading-spinner");
const $successModal = $("#success-modal"); const $successModal = $("#success-modal");
@@ -20,6 +23,7 @@ $(document).ready(() => {
initAuthors(allAuthors); initAuthors(allAuthors);
initGenres(allGenres); initGenres(allGenres);
initializeDropdownListeners(); initializeDropdownListeners();
initAiAssistant();
}) })
.catch((err) => { .catch((err) => {
console.error("Ошибка загрузки данных:", err); console.error("Ошибка загрузки данных:", err);
@@ -350,4 +354,483 @@ $(document).ready(() => {
window.location.href = "/books"; 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"); $form.removeClass("hidden");
$dangerZone.removeClass("hidden"); $dangerZone.removeClass("hidden");
$("#cancel-btn").attr("href", `/book/${bookId}`); $("#cancel-btn").attr("href", `/book/${bookId}`);
initAiAssistant();
}) })
.catch((error) => { .catch((error) => {
console.error(error); console.error(error);
Utils.showToast("Ошибка загрузки данных", "error"); Utils.showToast("Ошибка загрузки данных", "error");
setTimeout(() => (window.location.href = "/books"), 1500); // setTimeout(() => (window.location.href = "/books"), 1500);
}); });
function populateForm(book) { 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 %} {% 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="container mx-auto p-4 max-w-3xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8"> <div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4"> <div class="mb-8 border-b border-gray-100 pb-4">
@@ -25,6 +46,64 @@
</p> </p>
</div> </div>
<form id="create-book-form" class="space-y-6"> <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> <div>
<label <label
for="book-title" for="book-title"
+153 -295
View File
@@ -1,28 +1,35 @@
{% extends "base.html" %}{% block content %} {% 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="container mx-auto p-4 max-w-3xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8"> <div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4"> <div class="mb-8 border-b border-gray-100 pb-4">
<h1 <h1 class="text-2xl font-bold text-gray-800 flex items-center gap-3">
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
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> </svg>
<span>Редактирование книги</span> <span>Редактирование книги</span>
</h1> </h1>
<p class="text-gray-500 mt-2 text-sm ml-11"> <p class="text-gray-500 mt-2 text-sm ml-11">Измените информацию о книге, управляйте авторами и жанрами.</p>
Измените информацию о книге, управляйте авторами и жанрами.
</p>
</div> </div>
<div id="loader" class="animate-pulse space-y-4"> <div id="loader" class="animate-pulse space-y-4">
@@ -32,80 +39,97 @@
</div> </div>
<form id="edit-book-form" class="hidden space-y-6"> <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> <div>
<label <label for="book-title" class="block text-sm font-semibold text-gray-700 mb-2">
for="book-title"
class="block text-sm font-semibold text-gray-700 mb-2"
>
Название книги <span class="text-red-500">*</span> Название книги <span class="text-red-500">*</span>
</label> </label>
<input <input type="text" id="book-title" name="title" required maxlength="255"
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" 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"> <div class="flex justify-end mt-1">
<span id="title-counter" class="text-xs text-gray-400" <span id="title-counter" class="text-xs text-gray-400">0/255</span>
>0/255</span
>
</div> </div>
</div> </div>
<div> <div>
<label <label for="book-description" class="block text-sm font-semibold text-gray-700 mb-2">Описание</label>
for="book-description" <textarea id="book-description" name="description" rows="5" maxlength="2000"
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" 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="Краткое описание сюжета..." placeholder="Краткое описание сюжета..."></textarea>
></textarea>
<div class="flex justify-end mt-1"> <div class="flex justify-end mt-1">
<span id="desc-counter" class="text-xs text-gray-400" <span id="desc-counter" class="text-xs text-gray-400">0/2000</span>
>0/2000</span
>
</div> </div>
</div> </div>
<div> <div>
<label <label for="book-page-count" class="block text-sm font-semibold text-gray-700 mb-2">Количество страниц</label>
for="book-page-count" <input type="number" id="book-page-count" name="page_count" min="1"
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" 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>
<div> <div>
<label <label for="book-status" class="block text-sm font-semibold text-gray-700 mb-2">Статус</label>
for="book-status" <select id="book-status" name="status"
class="block text-sm font-semibold text-gray-700 mb-2" 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>
<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="active">Доступна</option>
<option value="borrowed">Выдана</option> <option value="borrowed">Выдана</option>
<option value="reserved">Забронирована</option> <option value="reserved">Забронирована</option>
@@ -116,156 +140,62 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="bg-white p-4 rounded-lg border border-gray-200"> <div class="bg-white p-4 rounded-lg border border-gray-200">
<h2 <h2 class="text-sm font-semibold text-gray-700 mb-3 flex items-center justify-between">
class="text-sm font-semibold text-gray-700 mb-3 flex items-center justify-between"
>
<span>Авторы</span> <span>Авторы</span>
<span <span id="authors-count" class="text-xs text-gray-400 font-normal"></span>
id="authors-count"
class="text-xs text-gray-400 font-normal"
></span>
</h2> </h2>
<div <div id="current-authors-container" class="flex flex-wrap gap-2 mb-3 min-h-[32px]"></div>
id="current-authors-container"
class="flex flex-wrap gap-2 mb-3 min-h-[32px]"
></div>
<div class="relative"> <div class="relative">
<input <input type="text" id="author-search-input" placeholder="Добавить автора..."
type="text" class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400" autocomplete="off" />
id="author-search-input" <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>
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> </div>
<div class="bg-white p-4 rounded-lg border border-gray-200"> <div class="bg-white p-4 rounded-lg border border-gray-200">
<h2 <h2 class="text-sm font-semibold text-gray-700 mb-3 flex items-center justify-between">
class="text-sm font-semibold text-gray-700 mb-3 flex items-center justify-between"
>
<span>Жанры</span> <span>Жанры</span>
<span <span id="genres-count" class="text-xs text-gray-400 font-normal"></span>
id="genres-count"
class="text-xs text-gray-400 font-normal"
></span>
</h2> </h2>
<div <div id="current-genres-container" class="flex flex-wrap gap-2 mb-3 min-h-[32px]"></div>
id="current-genres-container"
class="flex flex-wrap gap-2 mb-3 min-h-[32px]"
></div>
<div class="relative"> <div class="relative">
<input <input type="text" id="genre-search-input" placeholder="Добавить жанр..."
type="text" class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400" autocomplete="off" />
id="genre-search-input" <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>
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>
</div> </div>
<div <div class="flex flex-col sm:flex-row gap-3 pt-6 border-t border-gray-100">
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">
<button <svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
type="submit" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
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> </svg>
<span id="submit-text">Сохранить изменения</span> <span id="submit-text">Сохранить изменения</span>
<svg <svg id="loading-spinner" class="hidden animate-spin ml-2 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
id="loading-spinner" <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
class="hidden animate-spin ml-2 h-5 w-5 text-white" <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>
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> </svg>
</button> </button>
<a <a id="cancel-btn" href="#"
id="cancel-btn" 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">
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> </a>
</div> </div>
</form> </form>
<div id="danger-zone" class="hidden mt-8 pt-6 border-t border-red-200"> <div id="danger-zone" class="hidden mt-8 pt-6 border-t border-red-200">
<h3 <h3 class="text-lg font-bold text-red-600 mb-2 flex items-center gap-2">
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
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> </svg>
Опасная зона Опасная зона
</h3> </h3>
<p class="text-sm text-gray-600 mb-4"> <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">
</p> <svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<button <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>
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> </svg>
Удалить книгу Удалить книгу
</button> </button>
@@ -273,75 +203,32 @@
</div> </div>
</div> </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">
id="delete-modal" <div class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white">
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="mt-3 text-center">
<div <div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
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
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> </svg>
</div> </div>
<h3 class="text-lg leading-6 font-medium text-gray-900"> <h3 class="text-lg leading-6 font-medium text-gray-900">Удалить книгу?</h3>
Удалить книгу?
</h3>
<div class="mt-2 px-7 py-3"> <div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500"> <p class="text-sm text-gray-500">
Вы уверены, что хотите удалить книгу Вы уверены, что хотите удалить книгу
<span <span id="modal-book-title" class="font-bold text-gray-800"></span>? Это действие нельзя отменить.
id="modal-book-title"
class="font-bold text-gray-800"
></span
>? Это действие нельзя отменить.
</p> </p>
</div> </div>
<div class="flex gap-3 mt-4 justify-center"> <div class="flex gap-3 mt-4 justify-center">
<button <button id="confirm-delete-btn"
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">
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> <span>Удалить</span>
<svg <svg id="delete-spinner" class="hidden animate-spin ml-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
id="delete-spinner" <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
class="hidden animate-spin ml-2 h-4 w-4 text-white" <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>
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> </svg>
</button> </button>
<button <button id="cancel-delete-btn"
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">
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> </button>
</div> </div>
@@ -349,62 +236,33 @@
</div> </div>
</div> </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">
id="success-modal" <div class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white">
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="mt-3 text-center">
<div <div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4">
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
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> </svg>
</div> </div>
<h3 class="text-lg leading-6 font-medium text-gray-900"> <h3 class="text-lg leading-6 font-medium text-gray-900">Изменения сохранены!</h3>
Изменения сохранены!
</h3>
<div class="mt-2 px-7 py-3"> <div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500"> <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> </p>
</div> </div>
<div class="flex gap-3 mt-4 justify-center"> <div class="flex gap-3 mt-4 justify-center">
<a <a id="success-link-btn" href="#"
id="success-link-btn" 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">
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> </a>
<button <button id="success-close-btn"
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">
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> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% block scripts %} {% endblock %}{% block scripts %}
<script src="/static/page/edit_book.js"></script> <script src="/static/page/edit_book.js"></script>
{% endblock %} {% endblock %}