From d30b5614f4b6593aecad551fce3823c315d63a47 Mon Sep 17 00:00:00 2001 From: Damir Date: Fri, 10 Apr 2026 00:13:29 +0300 Subject: [PATCH] update --- backend/db.py | 2 +- backend/main.py | 64 +++++++++++++++++++++++++++++++-- backend/models.py | 18 +++++++++- frontend/ui.py | 92 +++++++++++++++++++++++++++++------------------ 4 files changed, 137 insertions(+), 39 deletions(-) diff --git a/backend/db.py b/backend/db.py index bcdb741..88a5ddb 100644 --- a/backend/db.py +++ b/backend/db.py @@ -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}" diff --git a/backend/main.py b/backend/main.py index 937188e..169db04 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/models.py b/backend/models.py index 3e99b4b..5d4ecdc 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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) + diff --git a/frontend/ui.py b/frontend/ui.py index 8952f12..507bb61 100644 --- a/frontend/ui.py +++ b/frontend/ui.py @@ -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,54 +215,57 @@ 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"]): st.markdown(m["content"].replace("$", r"\$")) - 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( - f"{backend_base_url}/chat", - json={ - "model": model, - "messages": st.session_state.chat_messages, - }, - headers=_auth_headers(), - timeout=120, - ) - 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"\$")) - except Exception as e: - reply = f"**Ошибка сети:** {e}" - st.error(str(e)) - st.session_state.chat_messages.append({"role": "assistant", "content": reply}) + user_text = st.chat_input("Напишите сообщение…", key="chat_input_portal") + if user_text: + st.session_state.chat_messages.append({"role": "user", "content": user_text}) + reply = "" + with st.spinner("Ollama отвечает…"): + try: + r = requests.post( + f"{backend_base_url}/chat", + json={ + "model": model, + "messages": st.session_state.chat_messages, + }, + headers=_auth_headers(), + timeout=120, + ) + if r.status_code >= 400: + reply = f"**Ошибка:** {_detail_or_text(r)}" + else: + reply = r.json().get("content") or "" + except Exception as e: + reply = f"**Ошибка сети:** {e}" + st.session_state.chat_messages.append({"role": "assistant", "content": reply}) + st.rerun() with tab_info: st.subheader("Сведения об АС/ФП")