Друзья, приветствую!
Как вы поняли по названию этой статьи — сегодня мы продолжаем погружаться в тему разработки личного ИИ-ассистента на основе собственных данных.
Краткое напоминание о первой части
Напоминаю, что в предыдущей части мы:
Разобрались с понятием векторной базы данных
Научились собирать данные и реализовывать такую базу
Изучили механизм "умного поиска"
Познакомились с мощным Python-инструментом Langchain
Обеспечили интеграцию нейросетей ChatGPT и Deepseek
Создали консольный вариант диалога с нейросетями на основе собственной базы знаний (документации сервиса Amvera Cloud)
О сегодняшней части
Сегодня мы прокачаем наш проект — превратим его в полноценный веб-сервис с авторизацией и удобным веб-чатом для взаимодействия с нейросетями. Получится некое подобие веб-интерфейса сайта https://chat.openai.com/chat, но с возможностью выбора между Deepseek и ChatGPT, работающими на основе нашей собственной базы знаний.
Для более детального понимания того, что мы будем создавать, я подготовил короткий видео-обзор с комментариями: Видео-демонстрация проекта
Необходимые предварительные условия
Для продолжения вам потребуются:
Готовая векторная база данных на собственных данных (не обязательно документация Amvera Cloud)
API-токены для ChatGPT и Deepseek
Базовое понимание принципов создания бэкенда на FastAPI и основ фронтенд-разработки
План работы
В ходе этой части мы:
Разработаем класс для удобного управления соединением с векторной базой Chroma (с возможностью однократного подключения)
Создадим класс для интеграции нейросетей Deepseek и ChatGPT в FastAPI-приложение с помощью Langchain
Разработаем API проекта для авторизации и отправки запросов к нейросетям
Создадим стильный веб-интерфейс чата со страницами входа и самого чата
Объединим фронтенд с бэкендом в формате полноценного защищенного веб-сервиса
Выполним деплой проекта на платформе Amvera Cloud
На выходе, после деплоя, мы получим такой результат:

Важное замечание о доступе к нейросетям
Отдельно хочу остановиться на особенностях удаленного запуска проекта. В России некоторые нейросети, такие как ChatGPT, официально недоступны даже через API-интеграцию, что обычно требует использования VPN.
Однако с сервисом Amvera Cloud такой проблемы не возникнет — вы сможете запускать собственные проекты с ChatGPT, Claude и другими нейросетями, недоступными в РФ. Кроме того, процесс деплоя даже такого сложного сервиса займет всего пару минут. Платформа предоставляет бесплатное доменное имя с HTTPS, что делает выбор этого сервиса очевидным.
Технический стек
В рамках статьи мы будем использовать два языка программирования:
Python (серверная логика):
langchain-openai — для интеграции с ChatGPT
langchain-deepseek — для интеграции с Deepseek
FastAPI — фреймворк для создания бэкенда
langchain-chroma — для взаимодействия с базой данных Chroma
Jinja2 — шаблонизатор фронтенда
PyJWT — для работы с JWT-токенами
И другие Python-инструменты
JavaScript (клиентская логика):
Чистый JavaScript без фреймворков
Несколько вспомогательных библиотек, интегрированных непосредственно в проект
Для стилизации будем частично писать собственные стили и частично использовать Bootstrap.
Итак, начнем разработку!
Подготовка проекта
Настройка окружения
Так как для рендеринга HTML страниц мы будем использовать Jinja2, всю логику мы опишем в рамках одного FastAPI приложения.
Начнем с создания виртуального окружения в вашем любимом IDE.
Создание конфигурационных файлов
Файл .env
Создадим файл .env
и заполним его следующими данными:
DEEPSEEK_API_KEY=sk-123456 # токен Deepseek
OPENAI_API_KEY=sk-proj-S_12345 # токен ChatGPT
SECRET_KEY=super_secret_key # секретный ключ для генерации JWT-токена (сами придумываем)
ALGORITHM=HS256 # алгоритм шифрования для JWT
Файл requirements.txt
Создадим файл requirements.txt
со следующим содержимым:
langchain-huggingface==0.1.2
torch==2.6.0
loguru==0.7.3
chromadb==0.6.3
sentence-transformers==3.4.1
langchain-chroma==0.2.2
pydantic-settings==2.8.1
fastapi==0.115.12
uvicorn==0.34.0
Jinja2==3.1.6
aiofiles==24.1.0
PyJWT==2.10.1
langchain-deepseek==0.1.3
langchain-openai==0.3.11
Установим зависимости:
pip install -r requirements.txt
Структура проекта
Создадим папку app
и внутри нее файл config.py
. Заполним его:
import os
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Config(BaseSettings):
DEEPSEEK_API_KEY: SecretStr
BASE_DIR: str = os.path.abspath(os.path.join(os.path.dirname(__file__)))
SECRET_KEY: str
USERS: str = os.path.join(BASE_DIR, "..", "users.json")
ALGORITHM: str
AMVERA_CHROMA_PATH: str = os.path.join(BASE_DIR, "chroma_db")
AMVERA_COLLECTION_NAME: str = "amvera_docs"
LM_MODEL_NAME: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
DEEPSEEK_MODEL_NAME: str = "deepseek-chat"
OPENAI_MODEL_NAME: str = "gpt-3.5-turbo"
OPENAI_API_KEY: SecretStr
model_config = SettingsConfigDict(env_file=f"{BASE_DIR}/../.env")
settings = Config()
Важное примечание: Обратите внимание на строку
from pydantic import SecretStr
. Для корректной интеграции через Langchain нейросетей Deepseek и ChatGPT необходимо описывать переменные окружения какDEEPSEEK_API_KEY: SecretStr
. В остальном — это просто удобное представление переменных окружения в одном классе при помощи библиотеки pydantic-settings.
Теперь нам достаточно будет импортировать settings
и везде по проекту вызывать необходимые переменные через точку: settings.OPENAI_API_KEY
Создание структуры директорий
В рамках подготовки давайте в папке app
создадим дополнительные директории:
api: опишем тут всю API-логику приложения
chroma_client: опишем класс для работы с Chroma и класс интеграции с нейросетями
chroma_db: тут необходимо поместить сгенерированную ранее базу данных Chroma (предполагаю, что база у вас уже есть, если это не так — можете брать мою. Базу можно найти в полном исходном коде проекта)
pages: тут опишем FastAPI эндпоинты для рендеринга HTML-страниц
static: папка со статическими файлами (стили и скрипты JS)
templates: папка с HTML-шаблонами
И сразу создадим в папке app
пустой файл main.py
. В нем мы будем собирать наше приложение.
На этом подготовка завершена, и мы можем приступать к написанию кода.
Класс для управления соединением с базой Chroma
Обоснование подхода
Напоминаю, что наша задача состоит в том, чтобы при запуске FastAPI приложения мы только один раз открывали соединение с базой данных Chroma, и после все запросы для всех пользователей должны использовать эту открытую сессию.
Это необходимо для:
Максимально быстрого получения контекста из Chroma для наших нейросетей
Корректной работы системы при одновременных запросах
Обеспечения гибкости даже при больших нагрузках
Реализация класса ChromaVectorStore
Создадим файл app/chroma_client/chroma_
store.py
и начнем с необходимых импортов:
import torch
from langchain_chroma import Chroma
from langchain_huggingface import HuggingFaceEmbeddings
from loguru import logger
from app.config import settings
Теперь реализуем сам класс для управления соединением с базой:
class ChromaVectorStore:
def __init__(self):
"""
Инициализирует пустой экземпляр хранилища векторов.
Соединение с базой данных будет установлено позже с помощью метода init().
"""
self._store: Chroma | None = None
async def init(self):
"""
Асинхронный метод для инициализации соединения с базой данных Chroma.
Создает embeddings на основе модели из настроек, используя CUDA если доступно.
"""
logger.info("🧠 Инициализация ChromaVectorStore...")
try:
# Определяем устройство для вычислений: GPU если доступен, иначе CPU
device = "cuda" if torch.cuda.is_available() else "cpu"
logger.info(f"🚀 Используем устройство для эмбеддингов: {device}")
# Создаем модель эмбеддингов с указанными параметрами
embeddings = HuggingFaceEmbeddings(
model_name=settings.LM_MODEL_NAME,
model_kwargs={"device": device},
encode_kwargs={"normalize_embeddings": True},
)
# Инициализируем соединение с базой данных Chroma
self._store = Chroma(
persist_directory=settings.AMVERA_CHROMA_PATH,
embedding_function=embeddings,
collection_name=settings.AMVERA_COLLECTION_NAME,
)
logger.success(
f"✅ ChromaVectorStore успешно подключен к коллекции "
f"'{settings.AMVERA_COLLECTION_NAME}' в '{settings.AMVERA_CHROMA_PATH}'"
)
except Exception as e:
logger.exception(f"❌ Ошибка при инициализации ChromaVectorStore: {e}")
raise
async def asimilarity_search(self, query: str, with_score: bool, k: int = 3):
"""
Асинхронный метод для поиска похожих документов в базе данных Chroma.
Args:
query (str): Текстовый запрос для поиска
with_score (bool): Включать ли оценку релевантности в результаты
k (int): Количество возвращаемых результатов
Returns:
list: Список найденных документов, возможно с оценками если with_score=True
Raises:
RuntimeError: Если хранилище не инициализировано
"""
if not self._store:
raise RuntimeError("ChromaVectorStore is not initialized.")
logger.info(f"🔍 Поиск похожих документов по запросу: «{query}», top_k={k}")
try:
if with_score:
results = await self._store.asimilarity_search_with_score(
query=query, k=k
)
else:
results = await self._store.asimilarity_search(query=query, k=k)
logger.debug(f"📄 Найдено {len(results)} результатов.")
return results
except Exception as e:
logger.exception(f"❌ Ошибка при поиске: {e}")
raise
async def close(self):
"""
Асинхронный метод для закрытия соединения с базой данных Chroma.
В текущей реализации Chroma не требует явного закрытия,
но метод добавлен для полноты API и возможных будущих изменений.
"""
logger.info("🔌 Отключение ChromaVectorStore...")
# Пока Chroma не требует явного закрытия, но в будущем может понадобиться
# self._store.close() или подобный метод
pass
Особенности реализации
В нашей реализации ChromaVectorStore
есть несколько ключевых моментов:
Отложенная инициализация — соединение с базой данных устанавливается только при явном вызове метода
init()
, что позволяет контролировать момент подключения в жизненном цикле приложения.Асинхронность — все методы являются асинхронными (
async
), что позволяет эффективно работать с базой данных в контексте асинхронного веб-приложения FastAPI.Использование GPU — если доступно CUDA-совместимое устройство, модель эмбеддингов будет использовать его для ускорения вычислений.
Логирование — все важные события и ошибки записываются в лог с помощью библиотеки
loguru
, что упрощает отладку и мониторинг.Обработка ошибок — все операции обернуты в блоки try-except для корректной обработки ошибок.
Данная реализация обеспечивает эффективное использование соединения с базой данных Chroma в рамках всего жизненного цикла приложения FastAPI.
К этому файлу мы ещё вернемся сегодня, но пока переключимся на реализацию класса для интеграции нейросетей Deepseek и ChatGPT в наш сервис.
Класс для интеграции нейросетей
Описание задачи
Прежде чем приступить к реализации класса, напомню о функциональных требованиях к нашему механизму взаимодействия с нейросетями:
Принимать текстовый запрос от пользователя
На основании запроса возвращать релевантные документы из базы данных Chroma
Формировать контекст из полученных данных Chroma, пользовательского запроса и системного промпта
Передавать этот контекст выбранной нейросети (ChatGPT или Deepseek)
Выдавать ответ не целым куском, а в формате потокового вывода (стрима)
Весь этот процесс должен работать асинхронно для обеспечения эффективной работы веб-приложения.
Реализация класса ChatWithAI
Создадим файл app/chroma_client/ai_
store.py
и начнем с необходимых импортов:
from typing import AsyncGenerator, Literal
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_deepseek import ChatDeepSeek
from langchain_openai import ChatOpenAI
from loguru import logger
from app.config import settings
Теперь реализуем класс для интеграции с нейросетями:
class ChatWithAI:
"""
Класс для взаимодействия с различными языковыми моделями (LLM).
Поддерживает работу с ChatGPT и Deepseek, с возможностью потокового вывода ответов.
"""
def __init__(self, provider: Literal["deepseek", "chatgpt"] = "deepseek"):
"""
Инициализирует экземпляр класса с выбранным провайдером LLM.
Args:
provider (str): Провайдер языковой модели ('deepseek' или 'chatgpt')
Raises:
ValueError: Если указан неподдерживаемый провайдер
"""
self.provider = provider
if provider == "deepseek":
logger.info(f"🤖 Инициализация Deepseek модели: {settings.DEEPSEEK_MODEL_NAME}")
self.llm = ChatDeepSeek(
api_key=settings.DEEPSEEK_API_KEY,
model=settings.DEEPSEEK_MODEL_NAME,
temperature=0.7, # Настройка творческой свободы модели
)
elif provider == "chatgpt":
logger.info(f"🤖 Инициализация ChatGPT модели: {settings.OPENAI_MODEL_NAME}")
self.llm = ChatOpenAI(
api_key=settings.OPENAI_API_KEY,
model=settings.OPENAI_MODEL_NAME,
temperature=0.7, # Настройка творческой свободы модели
)
else:
logger.error(f"❌ Неподдерживаемый провайдер: {provider}")
raise ValueError(f"Неподдерживаемый провайдер: {provider}")
logger.success(f"✅ Модель {provider} успешно инициализирована")
async def astream_response(
self, formatted_context: str, query: str
) -> AsyncGenerator[str, None]:
"""
Асинхронно генерирует потоковый ответ от выбранной языковой модели.
Args:
formatted_context (str): Контекст из базы знаний, форматированный для запроса
query (str): Пользовательский запрос
Yields:
str: Фрагменты ответа в потоковом режиме
Notes:
Использует системный промпт для задания контекста и роли модели
"""
try:
# Формируем системный промпт, определяющий роль и контекст работы модели
system_message = SystemMessage(
content="""Ты — внутренний менеджер компании Amvera Cloud.
Отвечаешь по делу без лишних вступлений.
Свой ответ, в первую очередь, ориентируй на переданный контекст.
Если информации недостаточно - пробуй получить ответы из своей базы знаний."""
)
# Формируем пользовательский запрос с включенным контекстом
human_message = HumanMessage(
content=f"Вопрос: {query}\nКонтекст: {formatted_context}. Ответ форматируй в markdown!"
)
logger.info(f"🔄 Начинаем стриминг ответа для запроса: «{query}»")
# Асинхронно получаем фрагменты ответа
async for chunk in self.llm.astream([system_message, human_message]):
if chunk.content: # Пропускаем пустые фрагменты
logger.debug(f"📝 Получен фрагмент: {chunk.content[:50]}...")
yield chunk.content
logger.info("✅ Стриминг ответа успешно завершен")
except Exception as e:
logger.error(f"❌ Ошибка при стриминге ответа: {e}")
yield f"Произошла ошибка при обработке запроса: {str(e)}"
Особенности реализации
Класс ChatWithAI
содержит несколько ключевых особенностей, которые стоит отметить:
Поддержка нескольких провайдеров — класс позволяет выбирать между ChatGPT от OpenAI и Deepseek, что дает гибкость в использовании различных языковых моделей в зависимости от потребностей и доступности.
Асинхронный потоковый вывод — метод
astream_response
реализован как асинхронный генератор, что позволяет постепенно передавать части ответа клиенту по мере их генерации моделью, без необходимости ожидать полный ответ.Форматирование запроса — для каждого запроса формируется специальный системный промпт, определяющий роль и контекст работы модели, что помогает получать более релевантные и структурированные ответы.
Расширенное логирование — все ключевые этапы работы класса сопровождаются детальным логированием, что упрощает отладку и мониторинг работы системы.
Обработка ошибок — все потенциально опасные операции обернуты в блоки try-except, что обеспечивает устойчивость работы приложения даже при возникновении ошибок взаимодействия с API нейросетей.
Реализуем систему авторизации
Так как в нашем сервисе будут задействованы платные токены — очевидно, что мы захотим защищать свой сервис от использования его сторонними людьми и в немеренных количествах.
Логика авторизации
Логика авторизации будет следующей:
Для пользования сервисом необходимо будет ввести логин и пароль в форму входа
После корректного ввода данных мы будем выдавать пользователю специальный JWT-токен, который мы будем помещать в куки сессию. У этого токена будет свой срок жизни (1 час). То есть после того как этот час пройдет — мы сделаем так, чтоб токен был недействительным. Недействительный токен равняется выходу из системы и полному закрытию функционала.
Если логин или пароль не корректны либо токен истек — доступа к системе у пользователя не будет.
Создание базы данных пользователей
Для экономии времени я решил использовать простой JSON в качестве базы данных пользователей - файл, в который поместил информацию о пользователях. Конечно, в боевом проекте — это должна быть реляционная база данных, в которой будет храниться полная информация о пользователе и его хэшированный пароль. Подробно об этом я рассказывал в своей статье: «Создание собственного API на Python (FastAPI): Авторизация, Аутентификация и роли пользователей» (https://habr.com/ru/articles/829742/)
В корне проекта (на одном уровне с папкой app) создаём файл users.json
и заполняем его следующим образом:
[
{
"user_id": 1,
"login": "user1",
"password": "pass1"
},
{
"user_id": 2,
"login": "user2",
"password": "pass2"
}
]
Если решите следовать этому примеру — позаботьтесь о надежности пароля. И ещё раз подчеркиваю — такой подход только для экономии времени!
Реализация утилит для авторизации
Теперь в папке app/api
создаем файл utils.py
, там опишем несколько утилит, которые позволят нам организовать логику авторизации в проекте.
Импорты:
import json
from datetime import datetime, timedelta
from typing import Optional
import aiofiles
import jwt
from fastapi import Cookie, HTTPException
from app.config import settings
Тут я использовал библиотеку aiofiles
для асинхронного взаимодействия с JSON-файлом.
Метод для получения всех пользователей:
async def get_all_users():
async with aiofiles.open(settings.USERS) as f:
content = await f.read()
users = json.loads(content)
return users
Метод для проверки корректности логина и пароля:
async def authenticate_user(login: str, password: str):
users = await get_all_users()
for user in users:
if user["login"] == login and user["password"] == password:
return user
return None
Метод для создания JWT токена:
async def create_jwt_token(user_id: int):
expire = datetime.now() + timedelta(hours=1)
payload = {
"sub": str(user_id),
"exp": expire.timestamp(),
}
token = jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return token
На вход принимаем айди пользователя и генерируем срок жизни токена в 1 час.
Метод для верификации JWT-токена:
async def verify_jwt_token(token: str):
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
return payload["sub"]
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
Тут мы на вход будем принимать токен и будем проверять валидность токена и его срок жизни. Только если токен валидный и его срок жизни не подошел к концу — мы будем возвращать айди пользователя, иначе выбрасываем ошибку.
Метод для проверки авторизации пользователя:
async def get_current_user(
access_token: Optional[str] = Cookie(default=None),
):
if not access_token:
raise HTTPException(status_code=401, detail="Missing token")
user_id = await verify_jwt_token(access_token)
return user_id
Тут мы пытаемся извлечь из куки значение ключа access_token
. Если его нет — сразу выбрасываем ошибку, что токен не найден.
Затем выполняем проверку валидности и срока жизни токена, и только если все ок — возвращаем айди пользователя, что будет равняться тому, что пользователь авторизован.
Опциональная проверка авторизации:
async def get_optional_current_user(
access_token: Optional[str] = Cookie(default=None),
) -> Optional[int]:
if not access_token:
return None
try:
user_id = await verify_jwt_token(access_token)
return user_id
except Exception:
return None
Данный метод от get_current_user
отличается только тем, что в случае если пользователь не авторизован — мы будем возвращать None
, а не ошибку. Этот метод нам пригодится, когда мы приступим к написанию веб-интерфейса.
Определение Pydantic-моделей
Теперь в папке api
создаем файл schemas.py
.
Там мы опишем pydantic-модели для валидации при авторизации пользователя:
from pydantic import BaseModel
class SUserAuth(BaseModel):
login: str
password: str
К этому файлу мы ещё вернемся.
Реализация API-роутов
Теперь в папке api
создаем файл router.py
и в нем опишем логику авторизации и проверки.
Импорты:
from fastapi import APIRouter, Depends, HTTPException, Response
from app.api.schemas import SUserAuth
from app.api.utils import authenticate_user, create_jwt_token, get_current_user
Инициализация роутера:
router = APIRouter()
Метод для входа:
@router.post("/login")
async def login(
response: Response,
user_data: SUserAuth,
):
user = await authenticate_user(user_data.login, user_data.password)
if not user:
raise HTTPException(status_code=401, detail="Invalid credentials")
token = await create_jwt_token(user["user_id"])
response.set_cookie(
key="access_token",
value=token,
httponly=True,
secure=False, # Включите True в проде (HTTPS)
samesite="lax",
max_age=3600,
path="/",
)
return {"message": "Logged in"}
На вход принимаем логин с паролем. Если все проверки пройдены, то создаем JWT-токен, который помещается в куки-сессию.
Метод для выхода:
@router.post("/logout")
async def logout(response: Response):
response.delete_cookie("access_token")
return {"message": "Logged out"}
Тут мы просто из кук удаляем ключ со значением access_token
.
Метод для проверки авторизации:
@router.get("/protected")
async def protected_route(user_id: int = Depends(get_current_user)):
return {"message": f"Привет, пользователь {user_id}!"}
Тут я использовал механизм зависимостей FastAPI. Подробнее о нем рассказывал в своих предыдущих статьях, посвященных FastAPI. Если коротко, то зависимость — это функция, которая выполняется до основной функции.
В нашем случае мы пытаемся получить айди пользователя. Если это удастся — значит пользователь авторизован. Иначе — мы выбросим ошибку. Для лучшего понимания продемонстрирую это через документацию:


На этом с блоком авторизации все, и мы можем приступать к реализации остальной API логики.
Пишем API-эндпоинты
Сейчас нам предстоит реализовать два API-эндпоинта.
Первый — для прямого взаимодействия с базой данных ChromaDB. Он нужен исключительно для тестирования: чтобы понять, как она работает и с какой скоростью.
Второй — уже полноценный, с подключением нейросети: он будет возвращать результат на основе данных из Chroma и ответа от AI.
Интеграция ChromaDB с FastAPI
Для корректной интеграции ChromaDB в наш FastAPI-сервис необходимо создать глобальный инстанс на основе класса ChromaVectorStore
, а также функцию-зависимость, которую мы будем использовать в эндпоинтах.
Сделаем это в файле:app/chroma_client/chroma_store.py
# Глобальный инстанс
chroma_vectorstore = ChromaVectorStore()
# Зависимость
def get_vectorstore() -> ChromaVectorStore:
return chroma_vectorstore
Pydantic-схемы
Теперь опишем модели для запросов и ответов в app/api/
schemas.py
:
from pydantic import BaseModel
from typing import Literal
class AskResponse(BaseModel):
response: str
class AskWithAIResponse(BaseModel):
response: str
provider: Literal["deepseek", "chatgpt"] = "deepseek"
Обновляем импорты
Переходим в app/api/
router.py
и актуализируем импорты:
from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi.responses import StreamingResponse
from app.api.schemas import AskResponse, AskWithAIResponse, SUserAuth
from app.api.utils import authenticate_user, create_jwt_token, get_current_user
from app.chroma_client.ai_store import ChatWithAI
from app.chroma_client.chroma_store import ChromaVectorStore, get_vectorstore
Здесь нам понадобится StreamingResponse
из FastAPI — мы будем использовать его во втором эндпоинте.
Эндпоинт №1: запрос к Chroma напрямую
Создаем эндпоинт для получения результатов из ChromaDB без подключения нейросети:
@router.post("/ask")
async def ask(
query: AskResponse,
vectorstore: ChromaVectorStore = Depends(get_vectorstore),
user_id: int = Depends(get_current_user),
):
results = await vectorstore.asimilarity_search(
query=query.response, with_score=True, k=5
)
formatted_results = []
for doc, score in results:
formatted_results.append({
"text": doc.page_content,
"metadata": doc.metadata,
"similarity_score": score,
})
return {"results": formatted_results}
На вход принимаем запрос пользователя. Используем две зависимости:
get_vectorstore
— для подключения к Chromaget_current_user
— для проверки авторизации пользователя
Проверим работу:

Как видите — всё прекрасно работает 🎉
Эндпоинт №2: Chroma + нейросеть
Теперь создаем второй эндпоинт: Chroma + нейросеть.
@router.post("/ask_with_ai")
async def ask_with_ai(
query: AskWithAIResponse,
vectorstore: ChromaVectorStore = Depends(get_vectorstore),
user_id: int = Depends(get_current_user),
):
results = await vectorstore.asimilarity_search(
query=query.response, with_score=True, k=5
)
if results:
ai_context = "\n".join([doc.page_content for doc, _ in results])
ai_store = ChatWithAI(provider=query.provider)
async def stream_response():
async for chunk in ai_store.astream_response(ai_context, query.response):
yield chunk
return StreamingResponse(
stream_response(),
media_type="text/plain",
headers={
"Content-Type": "text/plain",
"Transfer-Encoding": "chunked",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
)
else:
return {"response": "Ничего не найдено"}
Что здесь происходит:
Получаем документы из базы Chroma
Формируем AI-контекст — объединяем содержимое документов в одну строку, разделённую переносами
Инициализируем
ChatWithAI
с выбранным провайдером (deepseek
илиchatgpt
)Создаем генератор
stream_response
, который будет по кусочкам отдавать результат от нейросетиВозвращаем
StreamingResponse
с соответствующими заголовками
Важное замечание:
return StreamingResponse(
stream_response(),
media_type="text/plain",
headers={
"Content-Type": "text/plain",
"Transfer-Encoding": "chunked",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
)
Swagger не умеет обрабатывать стриминг, так что протестировать этот эндпоинт получится только через фронт — когда реализуем обработку стрима.
На этом с API-логикой всё. Дальше — разработка веб-интерфейса чата. Поехали! 🚀
Реализация веб-интерфейса
На этом этапе мы реализуем два HTML-шаблона — страницу входа и чат. Добавим к ним стили, JS-логику и создадим два FastAPI-эндпоинта, которые будут рендерить эти страницы.
1. Страница входа
Создаем HTML-шаблон login.html
по пути:app/templates/login.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Вход</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
<link rel="stylesheet" href="/static/form_style.css" />
</head>
<body>
<div class="login-form">
<h2 class="text-center mb-4">Вход в систему</h2>
<form id="loginForm">
<div class="form-floating mb-3">
<input
type="text"
class="form-control"
id="login"
name="login"
placeholder="Логин"
required
/>
<label for="login">Логин</label>
</div>
<div class="form-floating mb-4">
<input
type="password"
class="form-control"
id="password"
name="password"
placeholder="Пароль"
required
/>
<label for="password">Пароль</label>
</div>
<button type="submit" class="btn btn-primary w-100">Войти</button>
</form>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/form_script.js"></script>
</body>
</html>
📌 Это простая форма входа с использованием Bootstrap. Я также добавил кастомные стили (
form_style.css
), но на них подробно останавливаться не будем — при желании их можно посмотреть в исходниках проекта (см. мой Telegram-канал «Легкий путь в Python»).
JavaScript-логика для входа
JS-код для формы мы размещаем в:app/static/form_script.js
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault()
const login = document.getElementById('login').value
const password = document.getElementById('password').value
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ login, password }),
credentials: 'include',
})
const data = await response.json()
if (data.message === 'Logged in') {
window.location.href = '/'
} else {
alert('Ошибка входа')
}
} catch (error) {
console.error('Error:', error)
alert('Произошла ошибка при входе')
}
})
Здесь реализован стандартный подход:
Перехватываем отправку формы.
Забираем логин и пароль.
Отправляем
POST
-запрос на/api/login
.Если получили
Logged in
, то редиректим пользователя на главную.В случае ошибки — показываем
alert
.
2. Страница чата
Теперь создаем HTML-шаблон для чата:app/templates/index.html
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Amvera Cloud Chat</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
<link
href="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/github.min.css"
rel="stylesheet"
/>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"
rel="stylesheet"
/>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<div class="chat-container">
<div class="chat-header">
<h1>Amvera Cloud Chat</h1>
<div class="header-controls">
<select class="provider-select" id="provider">
<option value="deepseek">DeepSeek</option>
<option value="chatgpt">ChatGPT</option>
</select>
<button class="btn btn-outline-danger" id="logoutButton">
<i class="bi bi-box-arrow-right"></i>
Выйти
</button>
</div>
</div>
<div class="chat-output" id="chatOutput"></div>
<div class="input-container">
<input
type="text"
class="form-control"
id="queryInput"
placeholder="Введите ваш запрос..."
aria-label="Query"
/>
<button class="btn btn-primary" type="button" id="sendButton">
<i class="bi bi-send"></i>
Отправить
</button>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/highlight.min.js"></script>
<script src="/static/script.js"></script>
</body>
</html>
Здесь мы дополнительно подключили:
bootstrap-icons — для иконок
highlight.js — для подсветки синтаксиса
marked.js — для обработки markdown-ответов от нейросети
Следующим шагом мы реализуем основную JS-логику чата — в файле static/script.js
. Именно там будет обрабатываться ввод запроса, отправка на API, получение и отображение стриминга ответа.
Логика клиентской части: JavaScript для страницы чата
Теперь разберем ключевую часть клиентской логики — JavaScript-код, который обрабатывает взаимодействие пользователя с веб-интерфейсом чата.
Файл со скриптом: app/static/script.js
Настройка markdown и подсветки кода
Для корректного отображения ответов, особенно если это код, мы используем библиотеку marked и подключаем highlight.js для подсветки.
marked.setOptions({
highlight: function (code, lang) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value
}
return hljs.highlightAuto(code).value
},
breaks: false,
gfm: true,
headerIds: true,
mangle: false,
})
💡 Поддержка GFM (GitHub Flavored Markdown) включена, чтобы красиво отображались списки, заголовки и т.д. Подсветка работает как по указанному языку, так и в автоматическом режиме.
Вспомогательные функции: индикатор загрузки и отображение сообщений
Создание анимации загрузки:
function createLoadingIndicator() {
const loadingDiv = document.createElement('div')
loadingDiv.className = 'loading'
loadingDiv.innerHTML = `
<div class="loading-dot"></div>
<div class="loading-dot"></div>
<div class="loading-dot"></div>
`
return loadingDiv
}
Добавление сообщения в чат:
function addMessage(content, isUser = false) {
const chatOutput = document.getElementById('chatOutput')
const messageDiv = document.createElement('div')
messageDiv.className = `message ${isUser ? 'user' : 'assistant'}`
Сюда же добавляется кнопка копирования с иконкой. При клике — содержимое копируется в буфер обмена:
const copyButton = document.createElement('button')
copyButton.className = 'copy-button'
copyButton.innerHTML = '<i class="bi bi-clipboard"></i>'
Также добавляется блок с сообщением:
const contentDiv = document.createElement('div')
contentDiv.className = 'message-content'
contentDiv.innerHTML = marked.parse(content)
И в конце все элементы добавляются в DOM:
messageDiv.appendChild(contentDiv)
messageDiv.appendChild(copyButton)
chatOutput.appendChild(messageDiv)
chatOutput.scrollTop = chatOutput.scrollHeight
Обновление уже существующего сообщения
Если мы получаем ответ стримингом, то сообщение должно обновляться по мере поступления данных:
function updateMessage(messageDiv, content) {
const contentDiv = messageDiv.querySelector('.message-content') || document.createElement('div')
contentDiv.className = 'message-content'
contentDiv.innerHTML = marked.parse(content)
...
}
Отправка запроса
Теперь обработаем событие нажатия Enter или кнопку "Отправить":
document
.getElementById('queryInput')
.addEventListener('keypress', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
document.getElementById('sendButton').click()
}
})
Основная логика запроса к API
Вот что происходит при отправке запроса:
Проверка, ввел ли пользователь текст.
Добавляется пользовательское сообщение в чат.
Показывается индикатор загрузки.
Отправляется запрос на эндпоинт
/api/ask_with_ai
.Ответ читается потоком (stream).
Полученные символы по одному добавляются в чат с задержкой в 50 мс.
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
let currentText = ''
Затем происходит поочередное "считывание" символов и постепенное обновление текста ответа:
while (buffer.length > 0) {
const char = buffer[0]
buffer = buffer.slice(1)
currentText += char
await new Promise((resolve) => setTimeout(resolve, 50))
updateMessage(assistantMessage, currentText)
}
Обработка ошибок и выход из аккаунта
Если сервер вернул 401 — редиректим на страницу логина:
if (response.status === 401) {
window.location.href = '/login'
}
А при нажатии кнопки "Выйти":
document.getElementById('logoutButton').addEventListener('click', async () => {
await fetch('/api/logout', { method: 'POST', credentials: 'include' })
window.location.href = '/login'
})
На этом этапе у нас уже есть полноценный фронтенд:
Красивый интерфейс с Bootstrap и markdown
Поддержка стриминга ответов
Кнопка копирования
Обработка ошибок
Логика выхода
В следующем разделе мы создадим FastAPI-эндпоинты, которые будут рендерить эти HTML-шаблоны.
Эндпоинты для рендеринга HTML-страниц
На этом этапе мы подключаем HTML-шаблоны к FastAPI-приложению — опишем маршруты, которые будут отвечать за отображение страницы входа и страницы чата.
Создаем router.py
В папке app/pages
создаем новый файл:
app/pages/router.py
Импорты
Выполним все необходимые импорты:
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from app.api.utils import get_optional_current_user
💡 Здесь мы используем
Jinja2Templates
, чтобы рендерить HTML-шаблоны. А черезget_optional_current_user
проверяем, авторизован ли пользователь и выбрасываем None если это не так.
Инициализация роутера и шаблонизатора
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
Теперь templates.TemplateResponse(...)
будет искать шаблоны в папке app/templates
.
Эндпоинт: Страница входа
Добавим маршрут, обрабатывающий /login
:
@router.get("/login", response_class=HTMLResponse)
async def login_page(
request: Request,
user_id: int = Depends(get_optional_current_user),
):
if user_id:
return RedirectResponse(url="/")
else:
return templates.TemplateResponse("login.html", {"request": request})
Логика:
Если
user_id
найден (то есть пользователь уже авторизован) — выполняем редирект на главную.Иначе — возвращаем HTML-шаблон страницы входа (
login.html
).
Эндпоинт: Главная (чат)
Теперь опишем обработку главной страницы (/
), где будет отображаться наш чат:
@router.get("/", response_class=HTMLResponse)
async def chat_page(
request: Request,
user_id: int = Depends(get_optional_current_user),
):
if user_id:
return templates.TemplateResponse("index.html", {"request": request})
else:
return RedirectResponse(url="/login")
Здесь всё наоборот:
Если пользователь авторизован — рендерим
index.html
со страницей чата.Если нет — редиректим обратно на
/login
.
Теперь остается описать main-файл прилжения и можно делать запуск проекта!
Финальный штрих — main.py
На этом этапе мы соберём всё, что сделали ранее, в единый рабочий проект. Главный файл main.py
будет отвечать за запуск FastAPI-приложения, подключение всех маршрутов, инициализацию базы и статики.
Работаем в файле:
app/main.py
Импорты
Подключим всё необходимое:
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.api.router import router as api_router
from app.chroma_client.chroma_store import chroma_vectorstore
from app.pages.router import router as page_router
Здесь всё просто: мы импортируем роутеры, клиент для ChromaDB и необходимую утилиту для статики. Обратите внимание — я дал алиасы (
as api_router
,as page_router
), чтобы избежать путаницы.
Жизненный цикл приложения
Теперь определим lifespan
— это специальная функция, которая выполняется при старте и завершении приложения:
@asynccontextmanager
async def lifespan(app: FastAPI):
await chroma_vectorstore.init()
app.include_router(api_router, prefix="/api", tags=["API"])
app.include_router(page_router, tags=["ФРОНТ"])
app.mount("/static", StaticFiles(directory="app/static"), name="static")
yield
await chroma_vectorstore.close()
📌 Что здесь происходит:
Инициализируем ChromaDB — вызываем
init()
для подключения к базе.Подключаем API-роуты — с префиксом
/api
(чтобы все API-запросы были по/api/...
).Подключаем маршруты для рендеринга HTML — без префикса.
Подключаем статику — все файлы из
app/static
будут доступны как/static/...
.Закрываем ChromaDB при завершении работы.
Инициализация FastAPI-приложения
Теперь создаем само приложение и указываем ему функцию жизненного цикла:
app = FastAPI(lifespan=lifespan)
Запуск проекта
Открываем терминал и запускаем сервер командой:
uvicorn app.main:app --port 8000 --host 0.0.0.0
🔥 После запуска переходите в браузере на
http://localhost:8000
— и проверяйте работу проекта!
Остается только запустить наш проект удаленно и можно будет считать, что проект полностью завершен.
Деплой проекта на Amvera Cloud
Теперь, когда наш проект полностью готов к работе, самое время выкатить его в продакшн — чтобы любой, у кого есть ссылка и доступ (логин с паролем), мог им пользоваться. Для этого мы воспользуемся Amvera Cloud — удобной платформой для быстрого хостинга Python и прочих приложений. Тем более, что у нас уже есть всё необходимое.
Шаги по запуску проекта:
Регистрируемся на Amvera Cloud — если аккаунта ещё нет. Новичкам начисляется бонус на баланс, что особенно приятно.
Заходим во вкладку «Приложения»:https://cloud.amvera.ru/projects/applications
Жмём «Создать приложение».
Указываем имя проекта — только латиница, без пробелов и спецсимволов.
Выбираем тариф — не ниже "Начальный плюс", так как у нас используется векторная база (ChromaDB), и нужен минимум 1 ГБ ОЗУ.

На этапе выбора способа загрузки файлов:
Можно перетащить файлы проекта вручную в окно (без
venv
, он не нужен).Или подключить репозиторий через Git — в интерфейсе будут подробные инструкции.

Блок с переменными можно пропустить — ведь мы заранее подготовили файл
.env
.На финальном окне заполним инструкции как на скрине ниже:

Подключаем домен
После загрузки файлов и создания проекта:
Заходим в сам проект → вкладка «Домены»
Выбираем:
Бесплатный домен от Amvera (быстро и удобно)
или подключаем свой собственный домен
Пример домена от Amvera: https://amveraai-yakvenalex.amvera.io

Терпение — залог успеха
Из-за объёма зависимостей (и ChromaDB в том числе) первый запуск может занять 15–20 минут. Не переживай — это нормально.
После загрузки:
При переходе по привязанному домену вы увидите форму входа в чат
А в интерфейсе Amvera будет статус «Проект запущен»

Проверим:

Всё готово!
Поздравляю — мы написали и задеплоили полноценное AI-приложение! Оно теперь живёт в интернете и готово к работе с пользователями.
Заключение
Позади — насыщенное и увлекательное путешествие: от первой идеи до полноценного AI-приложения с векторной памятью и удобным веб-интерфейсом. Вместе мы прошли все ключевые этапы создания современной интеллектуальной системы.
Вот что удалось реализовать:
Построили чистую архитектуру на базе FastAPI, чётко разделив логику API и фронтенда;
Настроили рендеринг HTML-страниц, внедрили авторизацию и реализовали удобный чат-интерфейс;
Познакомились с тем, что такое векторная база данных, и научились использовать ChromaDB для хранения и поиска информации по векторным embeddings;
Связали базу данных с нейросетевой моделью, чтобы реализовать умный, контекстно-зависимый чат;
Настроили асинхронную обработку сообщений, добавили интерфейс с подсветкой кода, возможностью копирования и аккуратным UI;
Объединили всё в единый
main.py
, оформив маршруты и инициализацию всех компонентов проекта;И в финале — развернули проект на Amvera Cloud, сделав его доступным по ссылке для всех желающих.
Особенность этого проекта — прозрачность, простота архитектуры и минимум зависимостей. Всё сделано пошагово, чтобы вам было легко не только разобраться, но и развивать решение дальше.
Вы держите в руках не просто pet-проект, а реальную платформу: её можно масштабировать, адаптировать под заказчика или превратить в полноценный SaaS-сервис.
Если материал оказался полезным — поддержите статью лайком или комментарием. Полный исходный код, а также эксклюзивные материалы, которые не публикуются на Хабре, вы найдёте в моем бесплатном Telegram-канале «Лёгкий путь в Python». Нас там уже более 3300 участников — присоединяйтесь!
На этом всё. Увидимся в следующих проектах.