288 lines
12 KiB
Python
288 lines
12 KiB
Python
|
|
"""UI 组件:将 app.py 中大量 UI 渲染函数集中到这里,减少主入口文件体积。
|
|||
|
|
|
|||
|
|
说明:
|
|||
|
|
- 为了不大改原有逻辑,本文件基本搬运并小幅整理原 app.py 的函数。
|
|||
|
|
- HTML/CSS 仍有少量内联(Streamlit 常见),但我们会把大段模板集中在这里,避免 app.py 过长。
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import streamlit as st
|
|||
|
|
|
|||
|
|
from ui.rendering import render_opinion_preview, summarize_round_opinions
|
|||
|
|
|
|||
|
|
|
|||
|
|
def local_css(file_name: str) -> None:
|
|||
|
|
"""将本地 CSS 注入 Streamlit 页面"""
|
|||
|
|
try:
|
|||
|
|
with open(file_name, encoding="utf-8") as f:
|
|||
|
|
st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
|
|||
|
|
except FileNotFoundError:
|
|||
|
|
st.warning("未找到样式文件 style.css ,页面将使用默认样式。")
|
|||
|
|
except UnicodeDecodeError:
|
|||
|
|
st.warning("样式文件编码错误,页面将使用默认样式。")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def show_welcome_guide() -> None:
|
|||
|
|
"""显示新手引导(精简版:更适合展示,不喧宾夺主)"""
|
|||
|
|
with st.expander("📖 使用速览(30秒上手)", expanded=False):
|
|||
|
|
st.markdown(
|
|||
|
|
"""
|
|||
|
|
- **在左侧输入方案**:尽量包含目标/用户/约束/关键指标(至少 50 字)。
|
|||
|
|
- **选择评审角色**:建议 3-4 个角色,观点更全面。
|
|||
|
|
- **评审轮次**:当前固定为 **1 轮**(每个角色各输出一次观点)。
|
|||
|
|
- **点击开始评审**:首屏会给出**一页可执行结论**(结论/风险/行动清单/需澄清问题)。
|
|||
|
|
|
|||
|
|
> 提示:过程是“依据”,结论摘要是“答案”。
|
|||
|
|
"""
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def show_role_descriptions() -> dict:
|
|||
|
|
"""返回角色详细说明(UI 用)"""
|
|||
|
|
return {
|
|||
|
|
"product_manager": {
|
|||
|
|
"icon": "📊",
|
|||
|
|
"name": "产品经理",
|
|||
|
|
"focus": "用户需求、市场定位、产品价值",
|
|||
|
|
"description": "擅长从用户需求和市场角度分析方案,关注产品的价值和可行性。",
|
|||
|
|
},
|
|||
|
|
"tech_expert": {
|
|||
|
|
"icon": "💻",
|
|||
|
|
"name": "技术专家",
|
|||
|
|
"focus": "架构设计、技术风险、性能优化",
|
|||
|
|
"description": "擅长从技术实现角度分析方案,关注架构设计、技术风险和性能优化。",
|
|||
|
|
},
|
|||
|
|
"user_representative": {
|
|||
|
|
"icon": "👤",
|
|||
|
|
"name": "用户代表",
|
|||
|
|
"focus": "用户体验、易用性、实用性",
|
|||
|
|
"description": "擅长从实际使用角度分析方案,关注用户体验、易用性和实用性。",
|
|||
|
|
},
|
|||
|
|
"business_analyst": {
|
|||
|
|
"icon": "💰",
|
|||
|
|
"name": "商业分析师",
|
|||
|
|
"focus": "成本效益、投资回报、市场竞争力",
|
|||
|
|
"description": "擅长从商业价值角度分析方案,关注成本效益、投资回报率和市场竞争力。",
|
|||
|
|
},
|
|||
|
|
"designer": {
|
|||
|
|
"icon": "🎨",
|
|||
|
|
"name": "设计师",
|
|||
|
|
"focus": "视觉效果、交互设计、用户体验",
|
|||
|
|
"description": "擅长从设计角度分析方案,关注视觉效果、交互设计和用户体验。",
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def show_example_topic() -> str:
|
|||
|
|
return """我们计划开发一个AI辅助学习平台,主要功能包括:
|
|||
|
|
|
|||
|
|
1. **智能答疑系统**:基于大语言模型,能够回答学生在学习过程中的各种问题
|
|||
|
|
2. **个性化学习路径**:根据学生的学习进度和能力,自动推荐合适的学习内容和练习
|
|||
|
|
3. **学习数据分析**:收集和分析学生的学习数据,生成学习报告和改进建议
|
|||
|
|
4. **互动练习模块**:提供丰富的练习题和模拟考试,支持实时反馈和错题本功能
|
|||
|
|
|
|||
|
|
目标用户:高中生和大学生
|
|||
|
|
技术栈:Python + React + MongoDB + OpenAI API
|
|||
|
|
预期效果:提高学习效率 30%,用户满意度达到 4.5/5.0"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
def validate_input(topic: str, selected_agents: list[str]):
|
|||
|
|
"""验证用户输入"""
|
|||
|
|
errors: list[str] = []
|
|||
|
|
warnings: list[str] = []
|
|||
|
|
|
|||
|
|
if not topic or len(topic.strip()) < 50:
|
|||
|
|
errors.append("方案描述太短,请提供更详细的信息(至少50字)")
|
|||
|
|
|
|||
|
|
if not selected_agents or len(selected_agents) < 2:
|
|||
|
|
errors.append("请至少选择 2 个参与角色以获得全面的评估")
|
|||
|
|
|
|||
|
|
if topic and len(topic) > 5000:
|
|||
|
|
warnings.append("方案描述较长,可能会影响响应速度")
|
|||
|
|
|
|||
|
|
return errors, warnings
|
|||
|
|
|
|||
|
|
|
|||
|
|
def show_progress_indicator(current_step: int, total_steps: int, step_name: str) -> None:
|
|||
|
|
progress_percent = (current_step / total_steps) * 100
|
|||
|
|
st.markdown(
|
|||
|
|
f"""
|
|||
|
|
<div style="padding: 16px; background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 8px; margin-bottom: 20px;">
|
|||
|
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;">
|
|||
|
|
<span style="font-weight: 600; color: #1E293B;">{step_name}</span>
|
|||
|
|
<span style="font-size: 0.875rem; color: #64748B;">{current_step}/{total_steps}</span>
|
|||
|
|
</div>
|
|||
|
|
<div style="width: 100%; height: 6px; background: #E2E8F0; border-radius: 3px; overflow: hidden;">
|
|||
|
|
<div style="width: {progress_percent}%; height: 100%; background: linear-gradient(90deg, #3B82F6, #2563EB); border-radius: 3px; transition: width 0.3s ease;"></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
""",
|
|||
|
|
unsafe_allow_html=True,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def show_empty_state() -> None:
|
|||
|
|
st.markdown(
|
|||
|
|
"""
|
|||
|
|
<div style="text-align: center; padding: 80px 20px; color: #94A3B8;">
|
|||
|
|
<div style="font-size: 64px; margin-bottom: 16px;">🤖</div>
|
|||
|
|
<h3 style="color: #1E293B; margin-bottom: 12px;">准备开始评审</h3>
|
|||
|
|
<p style="font-size: 1.1rem; line-height: 1.6; max-width: 600px; margin: 0 auto;">
|
|||
|
|
在左侧配置您的方案并选择参与角色,然后点击"开始评审"按钮启动智能分析。
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
""",
|
|||
|
|
unsafe_allow_html=True,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def show_debate_summary(debate_history: list[dict], decision_points) -> None:
|
|||
|
|
if not debate_history:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
total_rounds = len(debate_history) - 1
|
|||
|
|
total_opinions = sum(len(round_data.get("opinions", {})) for round_data in debate_history[1:])
|
|||
|
|
|
|||
|
|
col1, col2, col3 = st.columns(3)
|
|||
|
|
|
|||
|
|
with col1:
|
|||
|
|
st.markdown(
|
|||
|
|
f"""
|
|||
|
|
<div class="stats-card">
|
|||
|
|
<div class="stats-label">评审轮次</div>
|
|||
|
|
<div class="stats-value">{total_rounds}</div>
|
|||
|
|
</div>
|
|||
|
|
""",
|
|||
|
|
unsafe_allow_html=True,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
with col2:
|
|||
|
|
st.markdown(
|
|||
|
|
f"""
|
|||
|
|
<div class="stats-card">
|
|||
|
|
<div class="stats-label">参与观点</div>
|
|||
|
|
<div class="stats-value">{total_opinions}</div>
|
|||
|
|
</div>
|
|||
|
|
""",
|
|||
|
|
unsafe_allow_html=True,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
with col3:
|
|||
|
|
st.markdown(
|
|||
|
|
f"""
|
|||
|
|
<div class="stats-card">
|
|||
|
|
<div class="stats-label">决策要点</div>
|
|||
|
|
<div class="stats-value">{len(decision_points) if decision_points else 0}</div>
|
|||
|
|
</div>
|
|||
|
|
""",
|
|||
|
|
unsafe_allow_html=True,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def show_debate_timeline(debate_history: list[dict], role_descriptions: dict) -> None:
|
|||
|
|
if not debate_history:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
st.markdown("---")
|
|||
|
|
st.subheader("📈 评审时间线")
|
|||
|
|
|
|||
|
|
for idx, round_data in enumerate(debate_history[1:], 1):
|
|||
|
|
round_type_map = {"initial_opinions": "初始观点", "interactive_debate": "互动讨论"}
|
|||
|
|
round_type = round_type_map.get(round_data["type"], round_data["type"])
|
|||
|
|
|
|||
|
|
# 新增:本轮摘要(可扫读),不删除原信息
|
|||
|
|
round_summary = summarize_round_opinions(round_data.get("opinions", {}), role_descriptions)
|
|||
|
|
|
|||
|
|
with st.container():
|
|||
|
|
st.markdown(
|
|||
|
|
f"""
|
|||
|
|
<div style="padding: 16px; background: #F8FAFC; border-left: 4px solid #3B82F6; border-radius: 0 8px 8px 0; margin-bottom: 16px;">
|
|||
|
|
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px;">
|
|||
|
|
<div style="width: 32px; height: 32px; background: #3B82F6; color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 0.875rem;">{idx}</div>
|
|||
|
|
<div>
|
|||
|
|
<div style="font-weight: 600; color: #1E293B; font-size: 1.1rem;">第{round_data['round']}轮:{round_type}</div>
|
|||
|
|
<div style="font-size: 0.875rem; color: #64748B; margin-top: 2px;">{len(round_data.get('opinions', {}))} 个角色参与讨论</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
""",
|
|||
|
|
unsafe_allow_html=True,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 摘要展示
|
|||
|
|
if round_summary:
|
|||
|
|
st.markdown("**本轮摘要:**")
|
|||
|
|
st.markdown(round_summary)
|
|||
|
|
|
|||
|
|
# 保留原有参与角色标签(不删)
|
|||
|
|
st.markdown('<div style="display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px;">', unsafe_allow_html=True)
|
|||
|
|
for role in round_data.get("opinions", {}).keys():
|
|||
|
|
role_info = role_descriptions.get(role, {"icon": "👤", "name": role})
|
|||
|
|
st.markdown(
|
|||
|
|
f"""
|
|||
|
|
<div style="padding: 6px 12px; background: #FFFFFF; border: 1px solid #E2E8F0; border-radius: 6px; font-size: 0.875rem; color: #64748B;">
|
|||
|
|
{role_info['icon']} {role_info['name']}
|
|||
|
|
</div>
|
|||
|
|
""",
|
|||
|
|
unsafe_allow_html=True,
|
|||
|
|
)
|
|||
|
|
st.markdown("</div></div>", unsafe_allow_html=True)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def show_debate_comparison(debate_history: list[dict], role_descriptions: dict) -> None:
|
|||
|
|
if not debate_history or len(debate_history) < 2:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
st.markdown("---")
|
|||
|
|
st.subheader("🔍 角色观点对比")
|
|||
|
|
|
|||
|
|
st.markdown(f"**共 {len(debate_history) - 1} 轮评审**")
|
|||
|
|
|
|||
|
|
for round_num, round_data in enumerate(debate_history[1:], 1):
|
|||
|
|
round_type_map = {"initial_opinions": "初始观点", "interactive_debate": "互动讨论"}
|
|||
|
|
round_type = round_type_map.get(round_data["type"], round_data["type"])
|
|||
|
|
|
|||
|
|
st.subheader(f"🔄 第{round_num}轮:{round_type}")
|
|||
|
|
st.markdown(f"**本轮参与角色:{len(round_data['opinions'])}个**")
|
|||
|
|
|
|||
|
|
for role, opinion in round_data["opinions"].items():
|
|||
|
|
role_info = role_descriptions.get(role, {"icon": "👤", "name": role})
|
|||
|
|
|
|||
|
|
preview = render_opinion_preview(opinion or "")
|
|||
|
|
title = f"{role_info['icon']} {role_info['name']}"
|
|||
|
|
if preview:
|
|||
|
|
title = f"{title} — {preview}"
|
|||
|
|
|
|||
|
|
with st.expander(title, expanded=False):
|
|||
|
|
if opinion:
|
|||
|
|
st.markdown(
|
|||
|
|
f"""
|
|||
|
|
<div style="padding: 4px 8px; background: #FFFFFF; border: 1px solid #E2E8F0; border-radius: 6px;">
|
|||
|
|
<div style="color: #1E293B; line-height: 1.6; white-space: pre-wrap;">{opinion}</div>
|
|||
|
|
</div>
|
|||
|
|
""",
|
|||
|
|
unsafe_allow_html=True,
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
st.warning(f"{role_info['name']} 在本轮没有生成观点")
|
|||
|
|
|
|||
|
|
st.markdown("---")
|
|||
|
|
st.subheader("📊 最终观点对比")
|
|||
|
|
|
|||
|
|
final_round = debate_history[-1]
|
|||
|
|
for role, opinion in final_round["opinions"].items():
|
|||
|
|
role_info = role_descriptions.get(role, {"icon": "👤", "name": role})
|
|||
|
|
|
|||
|
|
with st.container():
|
|||
|
|
st.markdown(
|
|||
|
|
f"""
|
|||
|
|
<div style="padding: 8px; background: #FFFFFF; border-left: 4px solid #3B82F6; border-radius: 0 8px 8px 0; margin-bottom: 8px;">
|
|||
|
|
<div style="display: flex; align-items: center; margin-bottom: 2px;">
|
|||
|
|
<span style="font-size: 20px; margin-right: 8px;">{role_info['icon']}</span>
|
|||
|
|
<strong style="color: #1E293B; font-size: 1.1rem;">{role_info['name']}</strong>
|
|||
|
|
</div>
|
|||
|
|
<div style="color: #1E293B; line-height: 1.6; white-space: pre-wrap;">{opinion}</div>
|
|||
|
|
</div>
|
|||
|
|
""",
|
|||
|
|
unsafe_allow_html=True,
|
|||
|
|
)
|