import json import os import uuid from datetime import datetime, timezone import requests import streamlit as st import yaml # В духе 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.session_state.autonomous_pl_loaded = False st.session_state.autonomous_phases = [] 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 _new_autonomous_phase() -> dict: return { "id": uuid.uuid4().hex, "name": "", "description": "", "jenkins": {"job": "", "server_url": "", "parameters": {}}, } def _mermaid_autonomous(phases: list) -> str: if not phases: return "flowchart LR\n E[Нет фаз]" lines = ["flowchart LR"] for i, p in enumerate(phases): label = (p.get("name") or f"Фаза {i + 1}").replace('"', "'").replace("\n", " ")[:48] lines.append(f' P{i}["{label}"]') for i in range(len(phases) - 1): lines.append(f" P{i} --> P{i + 1}") return "\n".join(lines) def _collect_autonomous_for_save(phases: list, ver: int) -> tuple[list | None, str | None]: out = [] for ph in phases: pid = ph["id"] j = ph.get("jenkins") or {} default_pj = json.dumps(j.get("parameters", {}), ensure_ascii=False, indent=2) raw = st.session_state.get(f"ap_pj_{pid}_{ver}", default_pj) try: params_obj = json.loads(raw) if str(raw).strip() else {} if not isinstance(params_obj, dict): raise TypeError("нужен JSON-объект { \"ключ\": \"значение\" }") except Exception as e: return None, f"Фаза «{ph.get('name') or pid}»: параметры Jenkins — {e}" out.append( { "id": pid, "name": st.session_state.get(f"ap_pn_{pid}_{ver}", ph.get("name", "")), "description": st.session_state.get(f"ap_pd_{pid}_{ver}", ph.get("description", "")), "jenkins": { "job": st.session_state.get(f"ap_jj_{pid}_{ver}", j.get("job", "")), "server_url": st.session_state.get(f"ap_js_{pid}_{ver}", j.get("server_url", "")), "parameters": {str(k): str(v) for k, v in params_obj.items()}, }, } ) return out, None def _phase_for_jenkins_trigger(ph: dict, ver: int) -> tuple[dict | None, str | None]: """Тело для POST /jenkins/trigger из текущих виджетов фазы.""" pid = ph["id"] j = ph.get("jenkins") or {} default_pj = json.dumps(j.get("parameters", {}), ensure_ascii=False, indent=2) raw = st.session_state.get(f"ap_pj_{pid}_{ver}", default_pj) try: params_obj = json.loads(raw) if str(raw).strip() else {} if not isinstance(params_obj, dict): raise TypeError("нужен JSON-объект") except Exception as e: return None, f"Параметры Jenkins: {e}" job = st.session_state.get(f"ap_jj_{pid}_{ver}", j.get("job", "")).strip() if not job: return None, "Укажите Job / путь." surl = st.session_state.get(f"ap_js_{pid}_{ver}", j.get("server_url", "")).strip() return ( { "server_url": surl, "job": job, "parameters": {str(k): str(v) for k, v in params_obj.items()}, }, None, ) 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.session_state.autonomous_pl_loaded = 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 if not st.session_state.get("autonomous_pl_loaded"): st.session_state.autonomous_phases = [] st.session_state.autonomous_pl_title = "" try: ar = requests.get( f"{backend_base_url}/pipelines/autonomous", headers=_auth_headers(), timeout=30, ) if ar.ok: data = ar.json() st.session_state.autonomous_pl_title = data.get("title") or "" phases = data.get("phases") or [] for ph in phases: if not isinstance(ph, dict): continue ph.setdefault("id", uuid.uuid4().hex) ph.setdefault("jenkins", {}) ph["jenkins"].setdefault("job", "") ph["jenkins"].setdefault("server_url", "") ph["jenkins"].setdefault("parameters", {}) st.session_state.autonomous_phases = [p for p in phases if isinstance(p, dict)] except Exception: pass st.session_state.autonomous_pl_loaded = True st.session_state.autonomous_pl_version = int(st.session_state.get("autonomous_pl_version", 0)) + 1 st.session_state.ap_global_title = st.session_state.get("autonomous_pl_title", "") # После входа — тот же заголовок + вкладки _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("Автономные внедрения") st.caption( "Low-code конвейер: фазы идут **слева направо**; под каждой фазой задаётся вызов **Jenkins** и параметры. " "Идея близка к DPM / Orchestra R ([статья на Хабре](https://habr.com/ru/companies/sberbank/articles/855556/), " "[документация Platform V DPM](https://sbertech.ru/documents/platform-v-devops-pipeline-management/4.0.0))." ) ver = int(st.session_state.get("autonomous_pl_version", 0)) phases: list = st.session_state.autonomous_phases c_head1, c_head2, c_head3 = st.columns([2, 1, 1]) with c_head1: if "ap_global_title" not in st.session_state: st.session_state.ap_global_title = st.session_state.get("autonomous_pl_title", "") st.text_input( "Название конвейера", key="ap_global_title", help="Сохраняется вместе с фазами в базе (один черновик на пользователя).", ) with c_head2: if st.button("Добавить фазу", use_container_width=True, key="ap_add_phase"): phases.append(_new_autonomous_phase()) st.session_state.autonomous_phases = phases st.session_state.autonomous_pl_version = ver + 1 st.rerun() with c_head3: if st.button("Перезагрузить с сервера", use_container_width=True, key="ap_reload"): st.session_state.autonomous_pl_loaded = False st.rerun() with st.expander("Схема потока (Mermaid)", expanded=False): st.markdown(f"```mermaid\n{_mermaid_autonomous(phases)}\n```") for idx, ph in enumerate(phases): pid = ph["id"] j = ph.setdefault("jenkins", {}) j.setdefault("parameters", {}) st.session_state.setdefault(f"ap_pn_{pid}_{ver}", ph.get("name", "")) st.session_state.setdefault(f"ap_pd_{pid}_{ver}", ph.get("description", "")) st.session_state.setdefault(f"ap_jj_{pid}_{ver}", j.get("job", "")) st.session_state.setdefault(f"ap_js_{pid}_{ver}", j.get("server_url", "")) st.session_state.setdefault( f"ap_pj_{pid}_{ver}", json.dumps(j.get("parameters", {}), ensure_ascii=False, indent=2), ) title_ph = st.session_state.get(f"ap_pn_{pid}_{ver}", "") or f"Фаза {idx + 1}" with st.expander(f"◆ {title_ph}", expanded=(idx == len(phases) - 1)): st.markdown("**Фаза (low-code)**") st.text_input("Название", key=f"ap_pn_{pid}_{ver}") st.text_area("Описание / что проверяется на этапе", key=f"ap_pd_{pid}_{ver}", height=68) st.divider() st.markdown("**Вызов Jenkins**") st.caption("Укажите job (путь в Jenkins) и при необходимости базовый URL контроллера; параметры уйдут в job как build parameters.") st.text_input("Job / путь", key=f"ap_jj_{pid}_{ver}", placeholder="folder/subfolder/job-name") st.text_input("Jenkins URL (если не дефолтный)", key=f"ap_js_{pid}_{ver}", placeholder="https://jenkins.example.com/") st.text_area( "Параметры (JSON-объект)", key=f"ap_pj_{pid}_{ver}", height=120, help='Например: {"BRANCH": "main", "ENV": "staging"}', ) b1, b2, b3 = st.columns(3) with b1: if st.button("Вверх", key=f"ap_up_{pid}_{ver}", disabled=idx == 0): phases[idx - 1], phases[idx] = phases[idx], phases[idx - 1] st.session_state.autonomous_phases = phases st.session_state.autonomous_pl_version = ver + 1 st.rerun() with b2: if st.button("Вниз", key=f"ap_dn_{pid}_{ver}", disabled=idx >= len(phases) - 1): phases[idx + 1], phases[idx] = phases[idx], phases[idx + 1] st.session_state.autonomous_phases = phases st.session_state.autonomous_pl_version = ver + 1 st.rerun() with b3: if st.button("Удалить фазу", key=f"ap_rm_{pid}_{ver}"): st.session_state.autonomous_phases = [p for p in phases if p["id"] != pid] st.session_state.autonomous_pl_version = ver + 1 st.rerun() if st.button( "Запустить Jenkins (build / buildWithParameters)", key=f"ap_run_{pid}_{ver}", use_container_width=True, ): pj, jerr = _phase_for_jenkins_trigger(ph, ver) if jerr: st.error(jerr) else: with st.spinner("Запрос к Jenkins API…"): try: jr = requests.post( f"{backend_base_url}/jenkins/trigger", json=pj, headers=_auth_headers(), timeout=120, ) if jr.status_code >= 400: st.error(_detail_or_text(jr)) else: info = jr.json() loc = info.get("location") or "" st.success( f"Сборка поставлена в очередь. {('Queue: ' + loc) if loc else ''}" ) except Exception as ex: st.error(str(ex)) save_col1, save_col2 = st.columns([1, 2]) with save_col1: if st.button("Сохранить в базу", type="primary", use_container_width=True, key="ap_save_db"): to_save, err = _collect_autonomous_for_save(phases, ver) if err: st.error(err) else: try: body = { "title": st.session_state.get("ap_global_title", ""), "phases": to_save, } r = requests.put( f"{backend_base_url}/pipelines/autonomous", json=body, headers=_auth_headers(), timeout=60, ) if r.status_code >= 400: st.error(_detail_or_text(r)) else: data = r.json() st.session_state.autonomous_pl_title = data.get("title", "") st.session_state.autonomous_phases = data.get("phases", []) if "ap_global_title" in st.session_state: del st.session_state["ap_global_title"] st.session_state.autonomous_pl_version = ver + 1 st.success("Сохранено.") st.rerun() except Exception as e: st.error(str(e)) with save_col2: st.caption( "Сохранение: `PUT /pipelines/autonomous`. Запуск: `POST /jenkins/trigger` " "(учётные данные Jenkins — только в env backend: `JENKINS_USER`, `JENKINS_TOKEN`)." ) with st.expander("Экспорт YAML (черновик)", expanded=False): snap, err = _collect_autonomous_for_save(phases, ver) if err: st.warning(err) else: doc = {"title": st.session_state.get("ap_global_title", ""), "phases": snap} st.code(yaml.safe_dump(doc, allow_unicode=True, sort_keys=False), language="yaml") 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 — внутренний портал.", )