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") port = _env("POSTGRES_PORT", "5432")
db = _env("POSTGRES_DB", "devops_portal") db = _env("POSTGRES_DB", "devops_portal")
user = _env("POSTGRES_USER", "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}" return f"postgresql+psycopg://{user}:{password}@{host}:{port}/{db}"

View File

@@ -6,7 +6,7 @@ import requests
import yaml import yaml
from fastapi import Depends, FastAPI, HTTPException from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import delete, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
@@ -39,7 +39,7 @@ from backend.auth import ( # noqa: E402
verify_password, verify_password,
) )
from backend.db import engine, get_db # noqa: E402 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): 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} 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") @app.post("/chat")
def chat_with_ollama( def chat_with_ollama(
req: ChatRequest, req: ChatRequest,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user) if REQUIRE_AUTH_FOR_GENERATE else None, user: User | None = Depends(get_current_user) if REQUIRE_AUTH_FOR_GENERATE else None,
): ):
if not req.messages: if not req.messages:
@@ -193,6 +230,29 @@ def chat_with_ollama(
content = msg.get("content", "") content = msg.get("content", "")
if not content and isinstance(data.get("response"), str): if not content and isinstance(data.get("response"), str):
content = data["response"] 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} return {"role": "assistant", "content": content}
except requests.HTTPError as e: except requests.HTTPError as e:
detail = e.response.text if e.response is not None else str(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 import uuid
from datetime import datetime, timezone 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.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 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) is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, 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(): def _logout():
st.session_state.token = None st.session_state.token = None
st.session_state.chat_messages = []
st.session_state.chat_hydrated = False
st.rerun() st.rerun()
st.button( st.button(
@@ -155,6 +157,8 @@ def _show_account():
st.error(f"Ошибка входа: {_detail_or_text(r)}") st.error(f"Ошибка входа: {_detail_or_text(r)}")
else: else:
st.session_state.token = r.json().get("access_token") st.session_state.token = r.json().get("access_token")
st.session_state.chat_messages = []
st.session_state.chat_hydrated = False
st.success("Успешный вход.") st.success("Успешный вход.")
st.rerun() st.rerun()
except Exception as e: except Exception as e:
@@ -180,13 +184,28 @@ if not st.session_state.token:
_show_account() _show_account()
st.stop() 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) _title_bar(show_logout=True)
st.caption("Портал с данными и инструментами для АС/ФП.") st.caption("Портал с данными и инструментами для АС/ФП.")
tab_chat, tab_info, tab_autonomous, tab_infra, tab_tools, tab_news = st.tabs( tab_chat, tab_info, tab_autonomous, tab_infra, tab_tools, tab_news = st.tabs(
[ [
"Чат с ИИ", "AI",
"Сведения об АС/ФП", "Сведения об АС/ФП",
"Автономные внедрения", "Автономные внедрения",
"Инфраструктура АС/ФП", "Инфраструктура АС/ФП",
@@ -196,54 +215,57 @@ tab_chat, tab_info, tab_autonomous, tab_infra, tab_tools, tab_news = st.tabs(
) )
with tab_chat: with tab_chat:
st.subheader("Чат с ИИ") st.subheader("AI")
st.caption( st.caption(
f"Запросы идут на backend `{backend_base_url}/chat`, а оттуда — в Ollama (`/api/chat`). " f"Диалог через `{backend_base_url}/chat` → Ollama. Модель: **{model}**. "
f"Модель: **{model}**." "Переписка сохраняется в базе для вашего пользователя."
) )
head_a, head_b = st.columns([4, 1]) _, head_b = st.columns([4, 1])
with head_b: with head_b:
def _clear_chat(): 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.session_state.chat_messages = []
st.rerun()
st.button("Очистить чат", on_click=_clear_chat, use_container_width=True, key="btn_clear_chat") st.button("Очистить чат", on_click=_clear_chat, use_container_width=True, key="btn_clear_chat")
# Только лента сообщений внутри рамки; st.chat_input — снаружи, чтобы строка была внизу экрана
with st.container(border=True): with st.container(border=True):
for m in st.session_state.chat_messages: for m in st.session_state.chat_messages:
with st.chat_message(m["role"]): with st.chat_message(m["role"]):
st.markdown(m["content"].replace("$", r"\$")) st.markdown(m["content"].replace("$", r"\$"))
user_text = st.chat_input("Напишите сообщение…", key="chat_input_portal") user_text = st.chat_input("Напишите сообщение…", key="chat_input_portal")
if user_text: if user_text:
st.session_state.chat_messages.append({"role": "user", "content": user_text}) st.session_state.chat_messages.append({"role": "user", "content": user_text})
with st.chat_message("user"): reply = ""
st.markdown(user_text.replace("$", r"\$")) with st.spinner("Ollama отвечает…"):
try:
reply = "" r = requests.post(
with st.chat_message("assistant"): f"{backend_base_url}/chat",
with st.spinner("Ollama отвечает…"): json={
try: "model": model,
r = requests.post( "messages": st.session_state.chat_messages,
f"{backend_base_url}/chat", },
json={ headers=_auth_headers(),
"model": model, timeout=120,
"messages": st.session_state.chat_messages, )
}, if r.status_code >= 400:
headers=_auth_headers(), reply = f"**Ошибка:** {_detail_or_text(r)}"
timeout=120, else:
) reply = r.json().get("content") or ""
if r.status_code >= 400: except Exception as e:
reply = f"**Ошибка:** {_detail_or_text(r)}" reply = f"**Ошибка сети:** {e}"
st.error(_detail_or_text(r)) st.session_state.chat_messages.append({"role": "assistant", "content": reply})
else: st.rerun()
data = r.json()
reply = data.get("content") or ""
st.markdown(reply.replace("$", r"\$"))
except Exception as e:
reply = f"**Ошибка сети:** {e}"
st.error(str(e))
st.session_state.chat_messages.append({"role": "assistant", "content": reply})
with tab_info: with tab_info:
st.subheader("Сведения об АС/ФП") st.subheader("Сведения об АС/ФП")