chatvns / app /ui.py
liamxdev's picture
Upload folder using huggingface_hub
34b531b verified
Raw
History Blame Contribute Delete
9.93 kB
from __future__ import annotations
import base64
from datetime import datetime, timezone
from pathlib import Path
import streamlit as st
try:
from streamlit_extras.metric_cards import style_metric_cards
except ImportError:
style_metric_cards = None
from app.config import PROJECT_ROOT
MASCOT_DIR = PROJECT_ROOT / "assets" / "mascots"
def mascot_path(name: str) -> Path:
path = MASCOT_DIR / f"{name}.png"
return path if path.exists() else MASCOT_DIR / "chatvns.png"
def mascot_data_uri(name: str) -> str:
path = mascot_path(name)
if not path.exists():
return ""
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
return f"data:image/png;base64,{encoded}"
def inject_app_css() -> None:
st.markdown(
"""
<style>
:root {
--chatvns-blue: #0877e8;
--chatvns-deep: #073b74;
--chatvns-cyan: #19b9f2;
--chatvns-ink: #10243e;
--chatvns-soft: #eef7ff;
}
[data-testid="stAppViewContainer"] {
background:
radial-gradient(circle at 82% 4%, rgba(25,185,242,.13), transparent 24rem),
radial-gradient(circle at 12% 18%, rgba(8,119,232,.09), transparent 28rem),
#f8fbff;
}
[data-testid="stSidebar"] {
background: linear-gradient(180deg, #061f3c 0%, #073b74 55%, #075da8 100%);
}
[data-testid="stSidebar"] * { color: #f6fbff; }
[data-testid="stSidebar"] .stButton button {
border: 1px solid rgba(255,255,255,.22);
background: rgba(255,255,255,.10);
color: white;
}
[data-testid="stSidebar"] .stButton button:hover {
border-color: #6ed7ff;
background: rgba(255,255,255,.18);
}
.block-container { padding-top: 5rem; padding-right: 2.5rem; padding-bottom: 5rem; padding-left: 2.5rem; }
.chatvns-hero {
display: flex; align-items: center; gap: 1.25rem;
padding: 1.15rem 1.35rem; margin-bottom: 1rem;
background: linear-gradient(125deg, rgba(255,255,255,.96), rgba(232,247,255,.94));
border: 1px solid rgba(8,119,232,.16); border-radius: 24px;
box-shadow: 0 16px 44px rgba(7,59,116,.10);
}
.chatvns-hero img { width: 112px; height: 112px; object-fit: contain; }
.chatvns-hero h1 { color: var(--chatvns-deep); margin: 0 0 .25rem; font-size: 2rem; }
.chatvns-hero p { color: #49647f; margin: 0; line-height: 1.55; }
.chatvns-kicker { color: var(--chatvns-blue); font-size: .76rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
.chatvns-pill-row { display:flex; gap:.45rem; flex-wrap:wrap; margin-top:.65rem; }
.chatvns-pill { padding:.3rem .65rem; border-radius:999px; background:#e7f4ff; color:#07579e; font-size:.78rem; font-weight:700; }
.chatvns-welcome {
text-align:center; max-width:720px; margin:2rem auto 1.2rem; padding:1.5rem;
}
.chatvns-welcome img { width:190px; filter: drop-shadow(0 16px 20px rgba(7,59,116,.16)); }
.chatvns-welcome h2 { color:var(--chatvns-deep); margin:.6rem 0 .35rem; }
.chatvns-welcome p { color:#5c7189; }
[data-testid="stChatMessage"] {
border: 1px solid rgba(8,119,232,.10); border-radius: 18px;
background: rgba(255,255,255,.88); padding: .35rem .65rem;
box-shadow: 0 7px 24px rgba(7,59,116,.055);
}
[data-testid="stChatInput"] { border-radius: 18px; }
[data-testid="stMetric"] {
background: rgba(255,255,255,.9); border:1px solid rgba(8,119,232,.12);
padding:.8rem 1rem; border-radius:16px; box-shadow:0 7px 22px rgba(7,59,116,.06);
}
.chatvns-trust {
display:flex; align-items:center; gap:.65rem; margin:.25rem 0 .7rem;
color:#315879; font-size:.86rem;
}
.chatvns-trust img { width:48px; height:48px; object-fit:contain; }
.chatvns-disclaimer {
margin-top:1.4rem; padding:.85rem 1rem; border-radius:14px;
background:#fff8e7; border:1px solid #f3d58b; color:#76551b; font-size:.84rem;
}
.chatvns-fresh { color:#0c7a55; font-weight:700; }
.chatvns-stale { color:#b16a00; font-weight:700; }
@media (max-width: 700px) {
.chatvns-hero { align-items:flex-start; }
.chatvns-hero img { width:78px; height:78px; }
.chatvns-hero h1 { font-size:1.55rem; }
}
.stMainBlockContainer .stButton button {
background: #FFFFFF;
border: 1px solid #1976D2;
color: #0A3D91;
}
.stMainBlockContainer .stButton button:hover {
background: #1976D2;
color: white;
}
.stBottom>* { background-color: unset; !important; }
.stBottom .stChatInput>div {
background: white;
border: 2px solid #1976D2;
}
.stBottom .stChatInput>div:focus {
border-color: #42A5F5;
box-shadow: 0 0 0 4px rgba(66,165,245,.2);
}
.stBottom .stChatInput>div>div>div>div>div {
background: white;
}
.stBottom .stChatInput textarea,
.stBottom .stChatInput textarea::placeholder {
color: #5c7189;
caret-color: #5c7189;
}
.stBottom .stChatInput button {
background: #FFFFFF;
border: 1px solid #1976D2;
color: #0A3D91;
}
.stBottom .stChatInput button>svg,
.stMetric>div>*,
.stHeading>div h3 {
color: #0A3D91;
}
.stExpander details summary {
background-color: rgb(43, 44, 54);
}
.stElementContainer .stMarkdown {
color: black;
}
</style>
""",
unsafe_allow_html=True,
)
def render_hero(ticker_count: int, top_k: int) -> None:
image_uri = mascot_data_uri("chatvns")
st.markdown(
f"""
<section class="chatvns-hero">
<img src="{image_uri}" alt="ChatVNS mascot">
<div>
<div class="chatvns-kicker">Trợ lý chứng khoán Việt Nam</div>
<h1>ChatVNS</h1>
<p>Trợ lý RAG cho dữ liệu chứng khoán Việt Nam — trả lời ngắn gọn, ưu tiên dữ liệu mới nhất và luôn chỉ rõ nguồn.</p>
<div class="chatvns-pill-row">
<span class="chatvns-pill">{ticker_count} mã có dữ liệu</span>
<span class="chatvns-pill">Truy xuất kết hợp · Top {top_k}</span>
<span class="chatvns-pill">Ưu tiên trích nguồn</span>
</div>
</div>
</section>
""",
unsafe_allow_html=True,
)
def render_welcome() -> None:
image_uri = mascot_data_uri("welcome")
st.markdown(
f"""
<section class="chatvns-welcome">
<img src="{image_uri}" alt="ChatVNS welcome mascot">
<h2>Chào bạn, mình là ChatVNS</h2>
<p>Hỏi về giá gần nhất, báo cáo doanh nghiệp, tin tức hoặc chỉ báo kỹ thuật. Chọn một gợi ý bên dưới để bắt đầu.</p>
</section>
""",
unsafe_allow_html=True,
)
def render_trust_note() -> None:
image_uri = mascot_data_uri("trust")
st.markdown(
f"""
<div class="chatvns-trust">
<img src="{image_uri}" alt="Source trust mascot">
<span>Nguồn được lấy từ dữ liệu đã thu thập và đưa trực tiếp vào ngữ cảnh của câu trả lời.</span>
</div>
""",
unsafe_allow_html=True,
)
def render_disclaimer() -> None:
st.markdown(
"""
<div class="chatvns-disclaimer">
<strong>Lưu ý:</strong> Nội dung do AI tổng hợp nhằm mục đích tham khảo, không phải khuyến nghị mua/bán hay tư vấn đầu tư cá nhân. Dữ liệu thị trường có thể có độ trễ; hãy kiểm tra lại với nguồn giao dịch chính thức trước khi ra quyết định.
</div>
""",
unsafe_allow_html=True,
)
def freshness_label(crawled_at: str | None) -> tuple[str, str]:
if not crawled_at:
return "Chưa xác định thời điểm cập nhật", "chatvns-stale"
try:
parsed = datetime.fromisoformat(str(crawled_at).replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
age = datetime.now(timezone.utc) - parsed.astimezone(timezone.utc)
minutes = max(0, int(age.total_seconds() // 60))
if minutes < 60:
return f"Cập nhật {minutes} phút trước", "chatvns-fresh"
hours = minutes // 60
if hours < 24:
return f"Cập nhật {hours} giờ trước", "chatvns-fresh" if hours < 4 else "chatvns-stale"
return f"Cập nhật {hours // 24} ngày trước", "chatvns-stale"
except (TypeError, ValueError):
return "Không đọc được thời điểm cập nhật", "chatvns-stale"
def apply_metric_card_style() -> None:
if style_metric_cards is not None:
style_metric_cards(
background_color="#ffffff",
border_left_color="#0877e8",
border_color="#d8eaff",
box_shadow=True,
)
def render_section_header(label: str, description: str = "") -> None:
st.subheader(label, divider="blue")
if description:
st.caption(description)