This commit is contained in:
2026-04-10 00:13:29 +03:00
parent 214c9495f3
commit d30b5614f4
4 changed files with 137 additions and 39 deletions

View File

@@ -16,7 +16,7 @@ def build_database_url() -> str:
port = _env("POSTGRES_PORT", "5432")
db = _env("POSTGRES_DB", "devops_portal")
user = _env("POSTGRES_USER", "devops_portal")
password = _env("POSTGRES_PASSWORD", "change-me")
password = _env("POSTGRES_PASSWORD", "ADzXo0Gm!")
return f"postgresql+psycopg://{user}:{password}@{host}:{port}/{db}"

View File

@@ -6,7 +6,7 @@ import requests
import yaml
from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from sqlalchemy.exc import SQLAlchemyError
@@ -39,7 +39,7 @@ from backend.auth import ( # noqa: E402
verify_password,
)
from backend.db import engine, get_db # noqa: E402
from backend.models import Base, User # noqa: E402
from backend.models import Base, User, UserChatMessage # noqa: E402
class GenerationRequest(BaseModel):
@@ -169,9 +169,46 @@ def me(user: User = Depends(get_current_user)):
return {"id": str(user.id), "email": user.email, "full_name": user.full_name, "is_admin": user.is_admin}
@app.get("/chat/history")
def chat_history(
limit: int = 500,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user) if REQUIRE_AUTH_FOR_GENERATE else None,
):
if user is None:
return {"messages": []}
lim = max(1, min(limit, 2000))
rows = (
db.execute(
select(UserChatMessage)
.where(UserChatMessage.user_id == user.id)
.order_by(UserChatMessage.created_at.asc())
.limit(lim)
)
.scalars()
.all()
)
return {
"messages": [{"role": r.role, "content": r.content} for r in rows],
}
@app.delete("/chat/history")
def chat_history_clear(
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user) if REQUIRE_AUTH_FOR_GENERATE else None,
):
if user is None:
return {"ok": True}
db.execute(delete(UserChatMessage).where(UserChatMessage.user_id == user.id))
db.commit()
return {"ok": True}
@app.post("/chat")
def chat_with_ollama(
req: ChatRequest,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user) if REQUIRE_AUTH_FOR_GENERATE else None,
):
if not req.messages:
@@ -193,6 +230,29 @@ def chat_with_ollama(
content = msg.get("content", "")
if not content and isinstance(data.get("response"), str):
content = data["response"]
if user is not None:
last = req.messages[-1]
if last.role == "user":
try:
db.add(
UserChatMessage(
user_id=user.id,
role="user",
content=last.content,
)
)
db.add(
UserChatMessage(
user_id=user.id,
role="assistant",
content=content,
)
)
db.commit()
except SQLAlchemyError:
db.rollback()
return {"role": "assistant", "content": content}
except requests.HTTPError as e:
detail = e.response.text if e.response is not None else str(e)

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, String
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -27,3 +27,19 @@ class User(Base):
is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, nullable=False)
class UserChatMessage(Base):
"""История чата с ИИ (Ollama) по пользователю."""
__tablename__ = "user_chat_messages"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
index=True,
nullable=False,
)
role: Mapped[str] = mapped_column(String(32), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, nullable=False)

View File

@@ -45,6 +45,8 @@ def _title_bar(*, show_logout: bool):
def _logout():
st.session_state.token = None
st.session_state.chat_messages = []
st.session_state.chat_hydrated = False
st.rerun()
st.button(
@@ -155,6 +157,8 @@ def _show_account():
st.error(f"Ошибка входа: {_detail_or_text(r)}")
else:
st.session_state.token = r.json().get("access_token")
st.session_state.chat_messages = []
st.session_state.chat_hydrated = False
st.success("Успешный вход.")
st.rerun()
except Exception as e:
@@ -180,13 +184,28 @@ if not st.session_state.token:
_show_account()
st.stop()
# Подтянуть историю чата из Postgres (один раз после входа / смены пользователя)
if not st.session_state.get("chat_hydrated"):
try:
hr = requests.get(
f"{backend_base_url}/chat/history",
headers=_auth_headers(),
params={"limit": 500},
timeout=30,
)
if hr.ok:
st.session_state.chat_messages = hr.json().get("messages", [])
except Exception:
pass
st.session_state.chat_hydrated = True
# После входа — тот же заголовок + вкладки
_title_bar(show_logout=True)
st.caption("Портал с данными и инструментами для АС/ФП.")
tab_chat, tab_info, tab_autonomous, tab_infra, tab_tools, tab_news = st.tabs(
[
"Чат с ИИ",
"AI",
"Сведения об АС/ФП",
"Автономные внедрения",
"Инфраструктура АС/ФП",
@@ -196,19 +215,29 @@ tab_chat, tab_info, tab_autonomous, tab_infra, tab_tools, tab_news = st.tabs(
)
with tab_chat:
st.subheader("Чат с ИИ")
st.subheader("AI")
st.caption(
f"Запросы идут на backend `{backend_base_url}/chat`, а оттуда — в Ollama (`/api/chat`). "
f"Модель: **{model}**."
f"Диалог через `{backend_base_url}/chat` → Ollama. Модель: **{model}**. "
"Переписка сохраняется в базе для вашего пользователя."
)
head_a, head_b = st.columns([4, 1])
_, head_b = st.columns([4, 1])
with head_b:
def _clear_chat():
try:
requests.delete(
f"{backend_base_url}/chat/history",
headers=_auth_headers(),
timeout=30,
)
except Exception:
pass
st.session_state.chat_messages = []
st.rerun()
st.button("Очистить чат", on_click=_clear_chat, use_container_width=True, key="btn_clear_chat")
# Только лента сообщений внутри рамки; st.chat_input — снаружи, чтобы строка была внизу экрана
with st.container(border=True):
for m in st.session_state.chat_messages:
with st.chat_message(m["role"]):
@@ -217,11 +246,7 @@ with tab_chat:
user_text = st.chat_input("Напишите сообщение…", key="chat_input_portal")
if user_text:
st.session_state.chat_messages.append({"role": "user", "content": user_text})
with st.chat_message("user"):
st.markdown(user_text.replace("$", r"\$"))
reply = ""
with st.chat_message("assistant"):
with st.spinner("Ollama отвечает…"):
try:
r = requests.post(
@@ -235,15 +260,12 @@ with tab_chat:
)
if r.status_code >= 400:
reply = f"**Ошибка:** {_detail_or_text(r)}"
st.error(_detail_or_text(r))
else:
data = r.json()
reply = data.get("content") or ""
st.markdown(reply.replace("$", r"\$"))
reply = r.json().get("content") or ""
except Exception as e:
reply = f"**Ошибка сети:** {e}"
st.error(str(e))
st.session_state.chat_messages.append({"role": "assistant", "content": reply})
st.rerun()
with tab_info:
st.subheader("Сведения об АС/ФП")