mirror of
https://github.com/wowlikon/LiB.git
synced 2026-03-21 15:43:38 +00:00
Исправление ошибок, добавление ИИ-ассистента
This commit is contained in:
+4
-4
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user