import os from datetime import datetime, timezone import requests import streamlit as st # В духе https://github.com/streamlit/demo-ai-assistant — крупный герой, заголовок в ряд с действием st.set_page_config( page_title="DevOps AI Assistant", page_icon="✨", layout="wide", initial_sidebar_state="expanded", ) _HERO_HTML = """
""" def _env(name: str, default: str) -> str: value = os.getenv(name) return value if value else default default_backend_base_url = _env("BACKEND_BASE_URL", "http://backend:8000") def _normalize_base_url(value: str) -> str: v = (value or "").strip().rstrip("/") if v.endswith("/generate"): v = v[: -len("/generate")] return v.rstrip("/") def _title_bar(*, show_logout: bool): st.html(_HERO_HTML) row = st.container(horizontal=True, vertical_alignment="bottom") with row: st.title("DevOps AI Assistant", anchor=False) if show_logout: def _logout(): st.session_state.token = None st.session_state.chat_messages = [] st.session_state.chat_hydrated = False st.rerun() st.button( "Выйти", icon=":material/logout:", on_click=_logout, help="Выйти из портала", ) with st.sidebar: st.markdown("#### Настройки") backend_base_url = _normalize_base_url(st.text_input("Backend URL", default_backend_base_url)) model = st.selectbox( "LLM модель", ["deepseek-r1:1.5b", "qwen2.5-coder:1.5b", "llama3.1:8b", "Arena Model"], ) platform = st.radio("Платформа", ["PV DOT CI", "PV DOT CD", "DPM CI", "DPM CD"]) st.divider() st.caption("Портал АС/ФП · чат и генератор используют выбранную LLM модель.") if "history" not in st.session_state: st.session_state.history = [] if "token" not in st.session_state: st.session_state.token = None if "asfp_info" not in st.session_state: st.session_state.asfp_info = { "name": "", "owner": "", "contacts": "", "description": "", } if "chat_messages" not in st.session_state: st.session_state.chat_messages = [] def _push_history(*, platform: str, model: str, prompt: str, status: str, config: str, error: str | None): st.session_state.history.insert( 0, { "ts": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"), "platform": platform, "model": model, "prompt": prompt, "status": status, "error": error, "config": config, }, ) st.session_state.history = st.session_state.history[:50] def _auth_headers(): if not st.session_state.token: return {} return {"Authorization": f"Bearer {st.session_state.token}"} def _detail_or_text(resp: requests.Response) -> str: try: payload = resp.json() if isinstance(payload, dict) and payload.get("detail"): return str(payload["detail"]) except Exception: pass return resp.text def _show_account(): _title_bar(show_logout=bool(st.session_state.token)) st.caption("Войдите или создайте учётную запись, чтобы открыть разделы портала.") # Два блока с рамкой — ближе к «карточному» виду демо c1, c2 = st.columns(2, gap="large") with c1: with st.container(border=True): st.markdown("**Регистрация**") reg_email = st.text_input("Email", key="reg_email") reg_full_name = st.text_input("Имя (опционально)", key="reg_full_name") reg_password = st.text_input("Пароль", type="password", key="reg_password") if st.button("Создать аккаунт", key="btn_register", use_container_width=True): try: r = requests.post( f"{backend_base_url}/auth/register", json={"email": reg_email, "password": reg_password, "full_name": reg_full_name or None}, timeout=30, ) if r.status_code >= 400: st.error(f"Ошибка регистрации: {_detail_or_text(r)}") else: st.success("Аккаунт создан. Теперь можно войти.") except Exception as e: st.error(f"Ошибка подключения к бекенду: {e}") with c2: with st.container(border=True): st.markdown("**Вход**") login_email = st.text_input("Email ", key="login_email") login_password = st.text_input("Пароль ", type="password", key="login_password") if st.button("Войти", type="primary", key="btn_login", use_container_width=True): try: r = requests.post( f"{backend_base_url}/auth/login", json={"email": login_email, "password": login_password}, timeout=30, ) if r.status_code >= 400: 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: st.error(f"Ошибка подключения к бекенду: {e}") st.divider() if st.session_state.token: try: r = requests.get(f"{backend_base_url}/auth/me", headers=_auth_headers(), timeout=20) if r.status_code >= 400: st.session_state.token = None st.warning("Сессия истекла или токен неверный. Войдите заново.") st.rerun() else: with st.expander("Профиль", expanded=False): st.json(r.json()) except Exception as e: st.error(f"Ошибка подключения к бекенду: {e}") # Auth wall 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", "Сведения об АС/ФП", "Автономные внедрения", "Инфраструктура АС/ФП", "Инструменты", "Новости", ] ) with tab_chat: st.subheader("AI") st.caption( f"Диалог через `{backend_base_url}/chat` → Ollama. Модель: **{model}**. " "Переписка сохраняется в базе для вашего пользователя." ) _, 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}) 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("Сведения об АС/ФП") st.caption("Черновик формы. Дальше можно привязать к Postgres.") with st.container(border=True): st.session_state.asfp_info["name"] = st.text_input("Название", st.session_state.asfp_info["name"]) st.session_state.asfp_info["owner"] = st.text_input("Владелец", st.session_state.asfp_info["owner"]) st.session_state.asfp_info["contacts"] = st.text_input("Контакты", st.session_state.asfp_info["contacts"]) st.session_state.asfp_info["description"] = st.text_area( "Описание", st.session_state.asfp_info["description"], height=120 ) with st.expander("JSON", expanded=False): st.json(st.session_state.asfp_info) with tab_autonomous: st.subheader("Автономные внедрения") with st.container(border=True): st.info( "Сюда можно добавить список внедрений, статус, даты, ответственных и ссылки на релизы/тикеты." ) with tab_infra: st.subheader("Инфраструктура АС/ФП") with st.container(border=True): st.info( "Сюда можно добавить окружения, кластера, сервисы, ресурсы, сетевые зависимости и т.д." ) with tab_tools: st.subheader("Инструменты") st.caption(f"Backend: `{backend_base_url}`") with st.container(border=True): st.markdown("#### Pipeline generator") user_input = st.text_area( "Опишите пайплайн (например: 'Python app with pytest and docker build')", height=150, key="generator_prompt", ) col_a, col_b = st.columns([1, 3]) with col_a: generate_clicked = st.button("Сгенерировать", type="primary", use_container_width=True) with col_b: st.caption("Чем конкретнее требования, тем стабильнее YAML.") if generate_clicked: if user_input.strip(): with st.spinner("LLM думает..."): try: res = requests.post( f"{backend_base_url}/generate", json={"description": user_input, "platform": platform, "model": model}, headers=_auth_headers(), timeout=90, ) res.raise_for_status() data = res.json() status = data.get("status", "error") config = data.get("config", "") err = data.get("validation_error") _push_history( platform=platform, model=model, prompt=user_input, status=status, config=config, error=err, ) if status == "ok": st.success("Конфигурация валидна.") else: st.error(f"Ошибка валидации: {err}") st.code(config, language="yaml") st.download_button("Скачать .yml", config, file_name="pipeline.yml") except Exception as e: st.error(f"Ошибка подключения к бекенду: {e}") else: st.warning("Пожалуйста, введите описание.") with tab_news: st.subheader("Новости") with st.container(border=True): st.info( "Релизы, инциденты, регламентные работы, обновления инструментов — сюда можно вывести ленту позже." ) st.divider() st.button( " :small[:gray[:material/balance: О портале]]", type="tertiary", help="DevOps AI Assistant — внутренний портал.", )