refs #701: modularize HMM console profile and scenarios

This commit is contained in:
devmrko
2026-07-22 12:52:31 +09:00
parent 97ed5d38ab
commit a2112e07dc
14 changed files with 538 additions and 378 deletions

View File

@@ -0,0 +1 @@
"""Reusable presentation shell for MCP-backed Streamlit agent consoles."""

View File

@@ -0,0 +1,72 @@
"""Shared, intentionally small Streamlit presentation primitives."""
from __future__ import annotations
from html import escape
from typing import Any
from .profile import AppProfile
def apply_console_theme(st: Any, profile: AppProfile) -> None:
"""Apply one predictable light theme from the product profile."""
st.markdown(
f"""
<style>
:root {{ color-scheme: light !important; --console-primary: {escape(profile.primary_color)};
--console-text: {escape(profile.text_color)}; --console-muted: {escape(profile.muted_color)};
--console-border: {escape(profile.border_color)}; }}
html, body, [data-testid="stAppViewContainer"], .stApp {{ background:#fff !important;
color:var(--console-text) !important; color-scheme:light !important;
font-family:"Noto Sans KR","Malgun Gothic",sans-serif; }}
header[data-testid="stHeader"] {{ display:none !important; }}
.block-container {{ max-width:1180px; padding:2rem 3rem 4rem; }}
section[data-testid="stSidebar"], section[data-testid="stSidebar"] > div {{ background:#fff !important; }}
section[data-testid="stSidebar"] {{ border-right:1px solid var(--console-border); }}
[data-testid="stAppViewContainer"] p, [data-testid="stAppViewContainer"] span,
[data-testid="stAppViewContainer"] label, [data-testid="stAppViewContainer"] h1,
[data-testid="stAppViewContainer"] h2, [data-testid="stAppViewContainer"] h3,
[data-testid="stAppViewContainer"] input, [data-testid="stAppViewContainer"] textarea,
section[data-testid="stSidebar"] * {{ color:var(--console-text) !important;
-webkit-text-fill-color:var(--console-text) !important; }}
input, textarea, [data-baseweb="select"] > div, [data-testid="stSidebar"] button {{
background:#fff !important; border:1px solid var(--console-border) !important;
border-radius:4px !important; box-shadow:none !important; }}
div[data-testid="stButton"] > button, div[data-testid="stFormSubmitButton"] > button {{
border-radius:4px !important; box-shadow:none !important; }}
div[data-testid="stButton"] > button[kind="primary"],
div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] {{
background:var(--console-primary) !important; border-color:var(--console-primary) !important; color:#fff !important; }}
div[data-testid="stButton"] > button[kind="primary"] *,
div[data-testid="stFormSubmitButton"] > button[data-testid="stBaseButton-primaryFormSubmit"] * {{
color:#fff !important; -webkit-text-fill-color:#fff !important; }}
.console-header {{ margin:0 0 28px; padding:0 0 22px; border-bottom:1px solid var(--console-border); }}
.console-wordmark {{ color:var(--console-primary); font-size:1.35rem; font-weight:800; letter-spacing:.08em; }}
.console-header h1 {{ margin:10px 0 8px; font-size:1.7rem; }}
.console-muted {{ color:var(--console-muted) !important; }}
.st-key-console_login_container {{ max-width:440px; margin:12vh auto 0; }}
.console-login {{ text-align:left; }}
.console-login h1 {{ margin:12px 0 8px; font-size:1.7rem; }}
@media (max-width:760px) {{ .block-container {{ padding:1.25rem 1.25rem 3rem; }} .st-key-console_login_container {{ margin-top:8vh; }} }}
</style>
""",
unsafe_allow_html=True,
)
def render_console_header(st: Any, profile: AppProfile) -> None:
st.markdown(
f"""<section class="console-header"><div class="console-wordmark">{escape(profile.short_name)}</div>
<h1>{escape(profile.header_title)}</h1><p class="console-muted">{escape(profile.header_description)}</p></section>""",
unsafe_allow_html=True,
)
def render_login_brand(st: Any, profile: AppProfile) -> None:
st.markdown(
f"""<section class="console-login"><div class="console-wordmark">{escape(profile.short_name)}</div>
<p class="console-muted">{escape(profile.login_kicker)}</p><h1>{escape(profile.login_title)}</h1>
<p class="console-muted">{escape(profile.login_description)}</p></section>""",
unsafe_allow_html=True,
)

View File

@@ -0,0 +1,118 @@
"""Configuration-backed product profile for a reusable agent console."""
from __future__ import annotations
from dataclasses import dataclass
import json
import os
from pathlib import Path
from typing import Any, Mapping
class AppProfileError(RuntimeError):
"""Safe error for an invalid product profile."""
@dataclass(frozen=True)
class AppProfile:
product_name: str
short_name: str
page_title: str
page_icon: str
header_title: str
header_description: str
login_kicker: str
login_title: str
login_description: str
login_footer: str
primary_color: str
text_color: str
muted_color: str
border_color: str
_ENV_FIELD_NAMES = {
"product_name": "AGENT_CONSOLE_NAME",
"short_name": "AGENT_CONSOLE_SHORT_NAME",
"page_title": "AGENT_CONSOLE_PAGE_TITLE",
"page_icon": "AGENT_CONSOLE_PAGE_ICON",
"header_title": "AGENT_CONSOLE_HEADER_TITLE",
"header_description": "AGENT_CONSOLE_HEADER_DESCRIPTION",
"login_kicker": "AGENT_CONSOLE_LOGIN_KICKER",
"login_title": "AGENT_CONSOLE_LOGIN_TITLE",
"login_description": "AGENT_CONSOLE_LOGIN_DESCRIPTION",
"login_footer": "AGENT_CONSOLE_LOGIN_FOOTER",
"primary_color": "AGENT_CONSOLE_PRIMARY_COLOR",
"text_color": "AGENT_CONSOLE_TEXT_COLOR",
"muted_color": "AGENT_CONSOLE_MUTED_COLOR",
"border_color": "AGENT_CONSOLE_BORDER_COLOR",
}
def _string(section: Mapping[str, Any], key: str, fallback: str = "") -> str:
return str(section.get(key) or fallback).strip()
def resolve_profile_path(default_path: Path) -> Path:
configured = os.environ.get("AGENT_CONSOLE_PROFILE_PATH", "").strip()
if not configured:
return default_path
path = Path(configured).expanduser()
return path if path.is_absolute() else default_path.parent / path
def _apply_environment_overrides(profile: AppProfile) -> AppProfile:
"""Apply deployment-specific presentation values without a code change."""
values = {
field_name: os.environ.get(env_name, "").strip() or getattr(profile, field_name)
for field_name, env_name in _ENV_FIELD_NAMES.items()
}
return AppProfile(**values)
def load_app_profile(default_path: Path) -> AppProfile:
"""Load the selectable product skin without coupling it to a PoC name."""
path = resolve_profile_path(default_path)
try:
payload: Any = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, ValueError):
raise AppProfileError(f"애플리케이션 프로필을 읽지 못했습니다: {path}") from None
if not isinstance(payload, Mapping):
raise AppProfileError("애플리케이션 프로필 형식이 올바르지 않습니다.")
product = payload.get("product")
theme = payload.get("theme")
if not isinstance(product, Mapping) or not isinstance(theme, Mapping):
raise AppProfileError("애플리케이션 프로필에 product와 theme 객체가 필요합니다.")
profile = _apply_environment_overrides(AppProfile(
product_name=_string(product, "name"),
short_name=_string(product, "short_name"),
page_title=_string(product, "page_title"),
page_icon=_string(product, "page_icon", "🤖"),
header_title=_string(product, "header_title"),
header_description=_string(product, "header_description"),
login_kicker=_string(product, "login_kicker"),
login_title=_string(product, "login_title"),
login_description=_string(product, "login_description"),
login_footer=_string(product, "login_footer"),
primary_color=_string(theme, "primary_color"),
text_color=_string(theme, "text_color"),
muted_color=_string(theme, "muted_color"),
border_color=_string(theme, "border_color"),
))
required = (
profile.product_name,
profile.short_name,
profile.page_title,
profile.header_title,
profile.login_title,
profile.primary_color,
profile.text_color,
profile.muted_color,
profile.border_color,
)
if not all(required):
raise AppProfileError("애플리케이션 프로필의 필수 표시값 또는 색상이 비어 있습니다.")
return profile