mirror of
https://github.com/wowlikon/LiB.git
synced 2026-03-21 23:53:38 +00:00
Исправление ошибок, добавление ИИ-ассистента
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 .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")
|
||||||
|
|||||||
@@ -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:
|
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
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
|
||||||
<label
|
<div id="ai-widget" class="hidden border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm transition-shadow hover:shadow-md">
|
||||||
for="book-title"
|
<div class="bg-gray-50 border-b border-gray-200 px-4 py-2.5 flex justify-between items-center select-none">
|
||||||
class="block text-sm font-semibold text-gray-700 mb-2"
|
<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">
|
||||||
Название книги <span class="text-red-500">*</span>
|
<svg class="w-5 h-5 text-gray-800" fill="currentColor" viewBox="0 0 24 24">
|
||||||
</label>
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="book-title"
|
id="ai-input"
|
||||||
name="title"
|
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"
|
||||||
required
|
placeholder="Напишите задачу (например: 'Придумай драматичное описание')..."
|
||||||
maxlength="255"
|
autocomplete="off"
|
||||||
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="Название книги..."
|
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-end mt-1">
|
<div class="flex border-l border-gray-100 items-center">
|
||||||
<span id="title-counter" class="text-xs text-gray-400"
|
<button type="button" id="ai-btn-stop"
|
||||||
>0/255</span
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label for="book-title" class="block text-sm font-semibold text-gray-700 mb-2">
|
||||||
for="book-description"
|
Название книги <span class="text-red-500">*</span>
|
||||||
class="block text-sm font-semibold text-gray-700 mb-2"
|
|
||||||
>
|
|
||||||
Описание
|
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<input type="text" id="book-title" name="title" required maxlength="255"
|
||||||
id="book-description"
|
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
|
||||||
name="description"
|
placeholder="Название книги..." />
|
||||||
rows="5"
|
<div class="flex justify-end mt-1">
|
||||||
maxlength="2000"
|
<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"
|
||||||
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,56 +236,27 @@
|
|||||||
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user