添加产品规格文档并优化项目结构

Made-with: Cursor
This commit is contained in:
刘国栋
2026-04-30 18:37:46 +08:00
parent bf2551d529
commit fb309299bf
101 changed files with 9586 additions and 14386 deletions
+25 -1
View File
@@ -6,4 +6,28 @@ Each top-level Tab in `geo_tool.py` should have a corresponding
invoked from the main app.
"""
from . import tab_keywords, tab_autowrite
from . import (
tab_keywords,
tab_autowrite,
tab_optimize,
tab_validation,
tab_history,
tab_reports,
tab_workflow,
tab_resources,
tab_platform_sync,
tab_config_optimizer,
)
__all__ = [
"tab_keywords",
"tab_autowrite",
"tab_optimize",
"tab_validation",
"tab_history",
"tab_reports",
"tab_workflow",
"tab_resources",
"tab_platform_sync",
"tab_config_optimizer",
]
+142
View File
@@ -0,0 +1,142 @@
"""
公共 UI 组件与工具函数,供各 tab_*.py 复用。
- 纯函数(如 sanitize_filename)不依赖 streamlit,避免循环导入。
- 渲染组件(如 render_section_header、render_download_button)依赖 streamlit。
"""
import re
import json
from typing import Callable, Optional, List, Any
import streamlit as st
# 文件名非法字符,与 geo_tool 主入口规则一致
INVALID_FS_CHARS = r'<>:"/\\|?*\n\r\t'
def sanitize_filename(name: str, max_len: int = 80) -> str:
"""将字符串清理为安全文件名(用于下载等)。各 Tab 统一由此处提供,避免重复实现。"""
if not name:
return "untitled"
name = name.strip()
name = re.sub(rf"[{re.escape(INVALID_FS_CHARS)}]", "_", name)
name = re.sub(r"_+", "_", name).strip("_")
return name[:max_len] if len(name) > max_len else name
def extract_json_array(text: str) -> Optional[List[Any]]:
"""从模型输出中抽取 JSON 数组(JsonOutputParser 失败时兜底)。"""
if not text:
return None
m = re.search(r"\[[\s\S]*\]", text)
if not m:
return None
try:
return json.loads(m.group(0))
except Exception:
return None
def safe_decode_uploaded(uploaded) -> str:
"""安全解码上传的文件内容"""
if not uploaded:
return ""
b = uploaded.getvalue()
for enc in ("utf-8-sig", "utf-8", "gb18030"):
try:
return b.decode(enc)
except Exception:
pass
return b.decode("utf-8", errors="replace")
def render_section_header(
title: str,
caption: Optional[str] = None,
level: int = 3,
) -> None:
"""渲染区块标题与可选说明。level=3 为 ###4 为 ####。"""
prefix = "#" * level
st.markdown(f"{prefix} {title}")
if caption:
st.caption(caption)
def render_download_button(
label: str,
data: str | bytes,
filename: str,
mime: str,
key: str,
use_container_width: bool = True,
) -> None:
"""统一风格的下载按钮。"""
st.download_button(
label,
data=data,
file_name=filename,
mime=mime,
key=key,
use_container_width=use_container_width,
)
def render_tab_top_with_clear(
title: str,
caption: Optional[str] = None,
clear_key: str = "tab_clear",
clear_label: str = "清空本模块结果",
on_clear: Optional[Callable[[], None]] = None,
) -> None:
"""
Tab 顶部:左侧标题+说明,右侧「清空本模块结果」按钮。
on_clear 若提供,会在点击清空时调用(可在此内清空 session_state 并 st.toast)。
"""
col_left, col_right = st.columns([3, 1])
with col_left:
st.markdown(f"**{title}**")
if caption:
st.caption(caption)
with col_right:
if st.button(clear_label, use_container_width=True, key=clear_key):
if on_clear:
on_clear()
st.toast("已清空。")
st.rerun()
# ========== 统一的消息提示组件 ==========
def show_success(message: str, toast: bool = True) -> None:
"""显示成功消息"""
st.success(message)
if toast:
st.toast(message, icon="")
def show_error(message: str, toast: bool = True) -> None:
"""显示错误消息"""
st.error(message)
if toast:
st.toast(message, icon="")
def show_warning(message: str, toast: bool = True) -> None:
"""显示警告消息"""
st.warning(message)
if toast:
st.toast(message, icon="⚠️")
def show_info(message: str, toast: bool = True) -> None:
"""显示信息消息"""
st.info(message)
if toast:
st.toast(message, icon="")
def show_loading(func, message: str = "加载中..."):
"""统一的加载状态包装器"""
with st.spinner(message):
return func()
+16 -27
View File
@@ -18,19 +18,8 @@ from modules.fact_density_enhancer import FactDensityEnhancer
from modules.multimodal_prompt import MultimodalPromptGenerator
from modules.optimization_techniques import OptimizationTechniqueManager
from modules.schema_generator import SchemaGenerator
INVALID_FS_CHARS = r'<>:"/\\|?*\n\r\t'
def sanitize_filename(name: str, max_len: int = 80) -> str:
"""Copy of utility from geo_tool, kept local to avoid circular imports."""
if not name:
return "untitled"
name = name.strip()
name = re.sub(rf"[{re.escape(INVALID_FS_CHARS)}]", "_", name)
name = re.sub(r"_+", "_", name).strip("_")
return name[:max_len] if len(name) > max_len else name
from modules.ui.components import render_tab_top_with_clear
from modules.ui.components import sanitize_filename
def render_tab_autowrite(
@@ -49,20 +38,20 @@ def render_tab_autowrite(
通过参数接收 storage / ss_init / gen_llm / brand / advantages / cfg /
record_api_cost / model_defaults,由主入口在 with tab2 内调用。
"""
# 标题和清空按钮放在同一行,布局更紧凑
header_col1, header_col2 = st.columns([4, 1])
with header_col1:
st.markdown("**✍️ 内容生成**")
st.caption("基于关键词自动生成符合 GEO 原则的专业内容,支持单篇和批量生成")
with header_col2:
st.markdown("")
if st.button("清空本模块结果", use_container_width=True, key="content_clear"):
st.session_state.generated_contents = []
st.session_state.zip_bytes = None
st.session_state.zip_filename = ""
st.session_state.content_scores = {}
st.session_state.selected_content_idx = 0
st.toast("创作内容已清空。")
# 标题和清空按钮
def _clear_content_state():
st.session_state.generated_contents = []
st.session_state.zip_bytes = None
st.session_state.zip_filename = ""
st.session_state.content_scores = {}
st.session_state.selected_content_idx = 0
render_tab_top_with_clear(
title="✍️ 内容生成",
caption="基于关键词自动生成符合 GEO 原则的专业内容,支持单篇和批量生成",
clear_key="content_clear",
on_clear=_clear_content_state,
)
if not st.session_state.keywords:
st.info("💡 请先在【🎯 关键词蒸馏】生成关键词。")
+297
View File
@@ -0,0 +1,297 @@
# Tab10:配置优化助手(从 geo_tool.py 迁移,通过 render_tab_config_optimizer() 供主入口调用。)
import hashlib
import streamlit as st
from modules.config_optimizer import ConfigOptimizer
def render_tab_config_optimizer(
storage,
cfg: dict,
brand: str,
advantages: str,
competitor_list: list,
build_llm,
model_defaults,
) -> None:
"""渲染 Tab10:配置优化助手。由主入口在 with tab10 内调用。"""
# 配置优化助手(与其他Tab保持一致的标题格式)
st.markdown("### 🎯 配置优化助手")
st.caption("分析品牌名和优势是否 GEO 友好,提供优化建议。优化后可一键应用到全局配置。")
# 初始化优化结果存储
if "config_optimization_result" not in st.session_state:
st.session_state.config_optimization_result = None
# 初始化配置hash(用于检测配置变化)
if "config_hash" not in st.session_state:
st.session_state.config_hash = None
# 计算当前配置的hash(使用cfg中的最新值)
brand_for_hash = cfg.get("brand", "").strip() or brand or ""
advantages_for_hash = cfg.get("advantages", "").strip() or advantages or ""
current_config_str = f"{brand_for_hash}|{advantages_for_hash}|{cfg.get('competitors', '')}"
current_config_hash = hashlib.md5(current_config_str.encode()).hexdigest()
# 如果配置变化了,清除旧的优化结果
# 但如果是因为应用版本导致的配置变化,保留优化结果
if st.session_state.config_hash != current_config_hash:
# 检查是否是应用版本导致的配置变化
if not st.session_state.get("_applying_version", False):
st.session_state.config_optimization_result = None
st.session_state.config_hash = current_config_hash
# 清除应用版本标志
st.session_state["_applying_version"] = False
# 检查配置是否有效
if not st.session_state.cfg_valid:
st.warning("⚠️ 请先在侧边栏完成配置并点击'应用配置'")
st.info("配置优化助手需要有效的配置才能进行分析。")
else:
# 显示当前配置
with st.expander("📋 当前配置", expanded=False):
col1, col2 = st.columns(2)
with col1:
brand_display = cfg.get("brand", "") or brand or "未设置"
st.markdown(f"**品牌名**{brand_display}")
with col2:
st.markdown(f"**竞品数量**{len(competitor_list)}")
advantages_display = cfg.get("advantages", "") or advantages or "未设置"
st.markdown(f"**核心优势**{advantages_display}")
if competitor_list:
st.markdown(f"**竞品列表**{', '.join(competitor_list[:5])}{'...' if len(competitor_list) > 5 else ''}")
# 分析按钮
col1, col2 = st.columns([1, 3])
with col1:
analyze_btn = st.button("🔍 分析配置优化", type="primary", use_container_width=True, key="tab10_optimize_config")
with col2:
if st.session_state.config_optimization_result:
st.success("✅ 已有优化结果,可直接查看下方建议")
# 执行分析
if analyze_btn:
with st.spinner("正在分析配置,优化建议生成中..."):
try:
optimizer = ConfigOptimizer()
# 从配置中获取品牌名、优势描述和竞品列表(确保使用最新配置)
brand_for_optimizer = cfg.get("brand", "").strip() or brand or ""
advantages_for_optimizer = cfg.get("advantages", "").strip() or advantages or ""
competitors_str = cfg.get("competitors", "")
competitor_list_for_optimizer = [c.strip() for c in competitors_str.split("\n") if c.strip()]
# 验证必要配置
if not brand_for_optimizer:
st.error("❌ 品牌名不能为空,请在侧边栏配置主品牌名称")
st.stop()
if not advantages_for_optimizer:
st.warning("⚠️ 优势描述为空,建议在侧边栏配置核心优势/卖点")
# 临时构建LLM用于分析(使用当前配置)
temp_llm = build_llm(
cfg["gen_provider"],
cfg["gen_api_key"],
model_defaults(cfg["gen_provider"]),
float(cfg.get("temperature", 0.7))
)
result = optimizer.optimize_config(
brand=brand_for_optimizer,
advantages=advantages_for_optimizer,
competitors=competitor_list_for_optimizer,
llm_chain=temp_llm
)
st.session_state.config_optimization_result = result
st.session_state.config_hash = current_config_hash
st.success("✅ 配置分析完成!")
st.rerun()
except Exception as e:
st.error(f"❌ 配置优化分析失败:{e}")
import traceback
with st.expander("查看错误详情"):
st.code(traceback.format_exc())
st.session_state.config_optimization_result = None
# 显示优化结果
if st.session_state.config_optimization_result:
result = st.session_state.config_optimization_result
if result.get("success", False):
st.markdown("---")
st.markdown("#### 📊 优化分析结果")
# 评估总结
if result.get("summary"):
st.markdown("**📝 评估总结**")
st.info(result["summary"])
# 优化建议
if result.get("suggestions"):
st.markdown("**💡 优化建议**")
suggestions = result["suggestions"]
if suggestions.get("brand", {}).get("problem"):
st.markdown("**🔸 品牌名问题**")
# 直接使用st.markdown渲染,CSS会限制标题大小
problem_text = suggestions["brand"]["problem"]
st.markdown(problem_text)
if suggestions["brand"].get("suggestion"):
st.markdown("**✅ 建议**")
suggestion_text = suggestions["brand"]["suggestion"]
st.markdown(suggestion_text)
if suggestions.get("advantages", {}).get("problem"):
st.markdown("**🔸 优势描述问题**")
problem_text = suggestions["advantages"]["problem"]
st.markdown(problem_text)
if suggestions["advantages"].get("suggestion"):
st.markdown("**✅ 建议**")
suggestion_text = suggestions["advantages"]["suggestion"]
st.markdown(suggestion_text)
# 推荐版本
recommended_versions = result.get("recommended_versions", [])
if recommended_versions:
st.markdown("**🎯 推荐版本**")
st.caption("选择最适合的版本,点击「应用版本」按钮即可更新配置")
# 检查是否有有效的推荐版本
valid_versions = [v for v in recommended_versions if v.get("brand") or v.get("advantages")]
if not valid_versions:
st.warning("⚠️ 推荐版本数据为空,可能是解析失败。请查看完整报告或重新分析。")
if result.get("raw_result"):
with st.expander("查看原始输出中的推荐版本部分"):
raw = result["raw_result"]
if "【推荐版本】" in raw:
raw_versions = raw.split("【推荐版本】")[1].split("")[0]
st.code(raw_versions)
for i, version in enumerate(recommended_versions[:3], 1):
version_name_map = {
1: "保守优化",
2: "平衡优化",
3: "激进优化"
}
version_name = version_name_map.get(i, f"版本{i}")
with st.expander(f"版本{i}{version_name}", expanded=False): # 默认不展开,用户自行选择
# 检查版本数据是否有效
has_brand = bool(version.get("brand", "").strip())
has_advantages = bool(version.get("advantages", "").strip())
has_reason = bool(version.get("reason", "").strip())
if not has_brand and not has_advantages:
st.warning("⚠️ 该版本数据不完整,请查看完整报告或重新分析")
if result.get("raw_result"):
with st.expander("查看原始输出中的该版本"):
# 尝试从原始输出中提取
raw = result["raw_result"]
if f"版本{i}" in raw:
version_raw = raw.split(f"版本{i}")[1]
if i < 3:
next_version = f"版本{i+1}"
if next_version in version_raw:
version_raw = version_raw.split(next_version)[0]
st.code(version_raw[:500]) # 显示前500字符
else:
col1, col2 = st.columns([2, 1])
with col1:
if has_brand:
st.markdown(f"**品牌名**`{version['brand']}`")
else:
st.warning("⚠️ 品牌名为空")
if has_advantages:
st.markdown(f"**优势描述**{version['advantages']}")
else:
st.warning("⚠️ 优势描述为空")
if has_reason:
st.caption(f"💭 理由:{version['reason']}")
else:
st.caption("💭 理由:未提供")
with col2:
# 应用按钮
apply_disabled = not (has_brand and has_advantages)
if st.button(
f"✅ 应用版本{i}",
key=f"tab10_apply_version_{i}",
use_container_width=True,
type="primary",
disabled=apply_disabled
):
if has_brand and has_advantages:
# 设置标志,表示正在应用版本(防止优化结果被清除)
st.session_state["_applying_version"] = True
# 更新配置
st.session_state.cfg["brand"] = version["brand"]
st.session_state.cfg["advantages"] = version["advantages"]
# 设置标志,表示需要更新侧边栏输入框
st.session_state["_pending_brand_update"] = version["brand"]
st.session_state["_pending_advantages_update"] = version["advantages"]
st.session_state.cfg_applied = False # 需要重新应用配置
st.success(f"✅ 已应用版本{i},侧边栏已更新,请点击'应用配置'以生效")
st.info("💡 配置更新后,建议重新运行关键词蒸馏和内容创作,以获得最佳效果")
st.rerun()
if apply_disabled:
st.caption("⚠️ 数据不完整,无法应用")
# 预期效果
if result.get("expected_effects"):
st.markdown("**📈 预期效果**")
effects = result["expected_effects"]
# 使用文本而不是 metric,避免内容被截断
if effects.get("mention_rate"):
st.markdown(f"- 提及率提升预期:{effects['mention_rate']}")
if effects.get("geo_friendliness"):
st.markdown(f"- GEO友好度提升:{effects['geo_friendliness']}")
# 完整报告
if result.get("raw_result"):
with st.expander("📄 查看完整分析报告", expanded=False):
st.markdown(result["raw_result"])
# 如果推荐版本为空或解析失败,显示原始输出中的推荐版本部分
recommended_versions = result.get("recommended_versions", [])
if not recommended_versions or all(
not v.get("brand") and not v.get("advantages")
for v in recommended_versions
):
st.warning("⚠️ 推荐版本解析失败,以下是原始输出中的推荐版本部分,请检查格式:")
raw = result["raw_result"]
if "【推荐版本】" in raw:
raw_versions = raw.split("【推荐版本】")[1].split("")[0]
st.code(raw_versions, language="text")
st.info("💡 如果原始输出中包含推荐版本但解析失败,请检查格式是否符合要求")
# 调试信息(可选)
if st.checkbox("🔍 显示调试信息", key="tab10_debug"):
st.markdown("#### 调试信息")
debug_info = {
"推荐版本数量": len(result.get("recommended_versions", [])),
"版本详情": result.get("recommended_versions", []),
"配置hash": st.session_state.config_hash,
"解析错误": result.get("parse_errors", [])
}
st.json(debug_info)
# 显示原始输出的关键部分
if result.get("raw_result"):
raw = result["raw_result"]
if "【推荐版本】" in raw:
st.markdown("**原始输出中的推荐版本部分:**")
raw_versions = raw.split("【推荐版本】")[1].split("")[0]
st.code(raw_versions[:1000], language="text") # 显示前1000字符
else:
st.error(f"❌ 分析失败:{result.get('error', '未知错误')}")
if result.get("raw_result"):
with st.expander("查看原始输出"):
st.code(result["raw_result"])
else:
st.info("💡 点击上方「分析配置优化」按钮开始分析,系统会根据当前配置生成优化建议。")
st.caption("提示:当您修改品牌名、优势描述或竞品列表后,系统会自动清除旧结果,需要重新分析。")
+115
View File
@@ -0,0 +1,115 @@
# Tab5:历史记录(从 geo_tool.py 迁移,通过 render_tab_history() 供主入口调用。)
import pandas as pd
import plotly.express as px
import streamlit as st
def render_tab_history(storage, brand: str) -> None:
"""渲染 Tab5:历史记录。由主入口在 with tab5 内调用。"""
st.header("历史记录")
# 统计数据
try:
stats = storage.get_stats(brand)
col1, col2, col3, col4 = st.columns(4)
col1.metric("关键词总数", stats["keywords_count"])
col2.metric("文章总数", stats["articles_count"])
col3.metric("优化记录", stats["optimizations_count"])
col4.metric("验证结果", stats["verify_results_count"])
except Exception as e:
st.error(f"获取统计数据失败:{e}")
stats = {"keywords_count": 0, "articles_count": 0, "optimizations_count": 0, "verify_results_count": 0}
st.markdown("---")
# 历史文章列表
st.markdown("#### 历史文章")
try:
articles = storage.get_articles(brand=brand)
if articles:
articles_df = pd.DataFrame(articles)
# 只显示关键列
display_cols = ["keyword", "platform", "created_at"]
available_cols = [col for col in display_cols if col in articles_df.columns]
if available_cols:
st.dataframe(articles_df[available_cols], use_container_width=True, hide_index=True)
else:
st.dataframe(articles_df, use_container_width=True, hide_index=True)
# 文章详情查看
if len(articles) > 0:
selected_idx = st.selectbox("选择文章查看详情", range(len(articles)), format_func=lambda x: f"{articles[x].get('keyword', 'N/A')} - {articles[x].get('platform', 'N/A')}")
if selected_idx is not None:
selected_article = articles[selected_idx]
with st.expander("文章内容", expanded=True):
if selected_article.get("content"):
if selected_article.get("platform", "").startswith("GitHub"):
st.code(selected_article["content"], language="markdown")
else:
st.text_area("内容", selected_article["content"], height=400, disabled=True, key=f"article_content_{selected_idx}")
else:
st.info("暂无历史文章记录。")
except Exception as e:
st.error(f"获取历史文章失败:{e}")
st.markdown("---")
# 历史优化记录
st.markdown("#### 历史优化记录")
try:
optimizations = storage.get_optimizations(brand=brand)
if optimizations:
opt_df = pd.DataFrame(optimizations)
display_cols = ["platform", "created_at"]
available_cols = [col for col in display_cols if col in opt_df.columns]
if available_cols:
st.dataframe(opt_df[available_cols], use_container_width=True, hide_index=True)
else:
st.dataframe(opt_df.head(10), use_container_width=True, hide_index=True)
if len(optimizations) > 0:
selected_opt_idx = st.selectbox("选择优化记录查看详情", range(len(optimizations)), format_func=lambda x: f"{optimizations[x].get('platform', 'N/A')} - {optimizations[x].get('created_at', 'N/A')[:10] if optimizations[x].get('created_at') else 'N/A'}")
if selected_opt_idx is not None:
selected_opt = optimizations[selected_opt_idx]
with st.expander("优化详情", expanded=True):
if selected_opt.get("changes"):
st.markdown("**变更说明**")
st.markdown(selected_opt["changes"])
if selected_opt.get("optimized_content"):
st.markdown("**优化后内容**")
if "GitHub" in selected_opt.get("platform", ""):
st.code(selected_opt["optimized_content"], language="markdown")
else:
st.text_area("内容", selected_opt["optimized_content"], height=300, disabled=True, key=f"opt_content_{selected_opt_idx}")
else:
st.info("暂无优化记录。")
except Exception as e:
st.error(f"获取优化记录失败:{e}")
st.markdown("---")
# 历史验证结果
st.markdown("#### 历史验证结果")
try:
verify_df = storage.get_verify_results(brand=brand)
if not verify_df.empty:
st.dataframe(verify_df, use_container_width=True, hide_index=True)
# 可视化历史验证结果
if len(verify_df) > 0:
st.markdown("#### 历史验证结果可视化")
fig = px.bar(
verify_df,
x="问题",
y="提及次数",
color="品牌",
facet_col="验证模型",
barmode="group",
title="历史验证结果对比",
)
st.plotly_chart(fig, use_container_width=True)
else:
st.info("暂无验证结果记录。")
except Exception as e:
st.error(f"获取验证结果失败:{e}")
+7 -37
View File
@@ -11,37 +11,7 @@ from langchain_core.prompts import PromptTemplate
from modules.keyword_mining import KeywordMining
from modules.semantic_expander import SemanticExpander
from modules.topic_cluster import TopicCluster
INVALID_FS_CHARS = r'<>:"/\\|?*\n\r\t'
def sanitize_filename(name: str, max_len: int = 80) -> str:
"""Copy of utility from geo_tool, kept local to avoid circular imports."""
if not name:
return "untitled"
name = name.strip()
# 延续主应用中的命名清理规则
import re # 局部导入,避免在模块顶部重复导入
name = re.sub(rf"[{re.escape(INVALID_FS_CHARS)}]", "_", name)
name = re.sub(r"_+", "_", name).strip("_")
return name[:max_len] if len(name) > max_len else name
def extract_json_array(text: str):
"""从模型输出中抽取 JSON 数组(JsonOutputParser 失败时兜底)。"""
if not text:
return None
import re
m = re.search(r"\[[\s\S]*\]", text)
if not m:
return None
try:
return json.loads(m.group(0))
except Exception:
return None
from modules.ui.components import sanitize_filename, extract_json_array
def render_tab_keywords(storage, ss_init, gen_llm, brand: str, advantages: str) -> None:
@@ -1134,16 +1104,16 @@ def render_tab_keywords(storage, ss_init, gen_llm, brand: str, advantages: str)
source_idx = next(
(
i
for i, n in enumerate(nodes)
if n["id"] == edge["source"]
for i, nd in enumerate(nodes)
if nd["id"] == edge["source"]
),
None,
)
target_idx = next(
(
i
for i, n in enumerate(nodes)
if n["id"] == edge["target"]
for i, nd in enumerate(nodes)
if nd["id"] == edge["target"]
),
None,
)
@@ -1384,12 +1354,12 @@ def render_tab_keywords(storage, ss_init, gen_llm, brand: str, advantages: str)
with st.container(border=True):
# 默认使用 brand,允许覆盖
default_industry = brand if brand else "外贸ERP"
default_industry = brand if brand else ""
industry = st.text_input(
"行业领域",
value=default_industry,
key="mining_industry",
help="输入您的行业领域,如:外贸ERP、AI工具、SaaS产品等",
help="输入您的行业领域,如:AI工具、SaaS产品、电商平台",
)
num_mine = st.slider("挖掘数量", 10, 50, 20, key="mining_num")
+242
View File
@@ -0,0 +1,242 @@
"""
知识库管理 Tab
支持上传文档、查看文档列表、搜索测试
"""
import streamlit as st
from modules.knowledge_base import KnowledgeBase, SourceVerifier
def render_tab_knowledge(kb: KnowledgeBase):
"""
渲染知识库管理 Tab
Args:
kb: 知识库实例
"""
st.markdown("### 📚 品牌知识库")
st.caption("上传品牌文档、产品手册、案例库,AI 生成内容时将自动检索引用")
# 统计信息
stats = kb.get_stats()
col1, col2, col3 = st.columns(3)
with col1:
st.metric("📄 文档数量", stats["total_documents"])
with col2:
st.metric("📦 分块数量", stats["total_chunks"])
with col3:
doc_types = stats.get("document_types", {})
st.metric("📋 文档类型", len(doc_types))
# 主要功能区域
kb_tab1, kb_tab2, kb_tab3, kb_tab4 = st.tabs([
"📤 上传文档", "📋 文档列表", "🔍 搜索测试", "📊 来源验证"
])
with kb_tab1:
_render_upload_section(kb)
with kb_tab2:
_render_document_list(kb)
with kb_tab3:
_render_search_test(kb)
with kb_tab4:
_render_source_verifier()
def _render_upload_section(kb: KnowledgeBase):
"""渲染上传文档区域"""
st.markdown("#### 上传新文档")
# 文档类型选择
doc_type = st.selectbox(
"文档类型",
["text", "faq", "product", "case", "markdown"],
format_func=lambda x: {
"text": "📝 通用文本",
"faq": "❓ FAQ 问答",
"product": "📦 产品文档",
"case": "💼 客户案例",
"markdown": "📑 Markdown 文档"
}.get(x, x),
help="选择文档类型有助于更精准的分块和检索"
)
# 上传方式选择
upload_method = st.radio(
"上传方式",
["📁 上传文件", "📝 粘贴文本"],
horizontal=True
)
if upload_method == "📁 上传文件":
uploaded_file = st.file_uploader(
"选择文件",
type=["txt", "md", "csv"],
help="支持 TXT、Markdown、CSV 格式"
)
if uploaded_file:
content = uploaded_file.read().decode("utf-8")
st.text_area("文件预览", content[:1000] + "..." if len(content) > 1000 else content,
height=150, disabled=True)
if st.button("📥 导入知识库", use_container_width=True, type="primary"):
with st.spinner("正在处理文档..."):
result = kb.add_document(
filename=uploaded_file.name,
content=content,
doc_type=doc_type
)
st.success(f"✅ 文档 '{result['filename']}' 已导入,分为 {result['chunk_count']} 个分块")
st.rerun()
else:
filename = st.text_input("文档名称", placeholder="例如:产品功能说明")
content = st.text_area("粘贴文档内容", height=300,
placeholder="粘贴品牌介绍、产品说明、FAQ 等内容...")
if st.button("📥 导入知识库", use_container_width=True, type="primary"):
if not filename:
st.warning("请输入文档名称")
elif not content.strip():
st.warning("请输入文档内容")
else:
with st.spinner("正在处理文档..."):
result = kb.add_document(
filename=filename,
content=content,
doc_type=doc_type
)
st.success(f"✅ 文档 '{result['filename']}' 已导入,分为 {result['chunk_count']} 个分块")
st.rerun()
# 批量导入示例
with st.expander("💡 快速导入示例数据"):
st.markdown("""
**FAQ 示例格式:**
```
Q:你们的产品有什么优势?
A:我们的产品具有以下核心优势:1)AI深度赋能...;2)全流程覆盖...;3)数据驱动决策...
Q:如何开始使用?
A:只需三步:1)注册账号;2)配置基础信息;3)开始使用核心功能。
```
**产品文档示例格式:**
```
# 产品概述
产品简介...
# 核心功能
功能说明...
# 技术架构
架构说明...
```
""")
def _render_document_list(kb: KnowledgeBase):
"""渲染文档列表区域"""
st.markdown("#### 已导入文档")
documents = kb.list_documents()
if not documents:
st.info("📭 知识库为空,请先上传文档")
return
for doc in documents:
with st.expander(f"📄 {doc['filename']} ({doc['doc_type']})"):
col1, col2, col3 = st.columns(3)
with col1:
st.write(f"**类型:** {doc['doc_type']}")
with col2:
st.write(f"**分块数:** {doc['chunk_count']}")
with col3:
st.write(f"**导入时间:** {doc['created_at'][:10]}")
if st.button(f"🗑️ 删除", key=f"delete_{doc['doc_id']}"):
kb.delete_document(doc['doc_id'])
st.success(f"已删除文档 '{doc['filename']}'")
st.rerun()
def _render_search_test(kb: KnowledgeBase):
"""渲染搜索测试区域"""
st.markdown("#### 搜索测试")
st.caption("测试知识库检索效果,验证文档是否被正确索引")
query = st.text_input("输入测试查询", placeholder="例如:产品有什么优势?")
col1, col2 = st.columns(2)
with col1:
top_k = st.slider("返回结果数", 1, 10, 3)
with col2:
doc_type_filter = st.selectbox(
"过滤文档类型",
["全部"] + ["text", "faq", "product", "case", "markdown"],
index=0
)
if query:
doc_type = None if doc_type_filter == "全部" else doc_type_filter
results = kb.search(query, top_k=top_k, doc_type=doc_type)
if results:
st.markdown(f"**找到 {len(results)} 条相关结果:**")
for i, result in enumerate(results, 1):
with st.expander(f"结果 {i} (相关度: {result['score']:.2f})"):
st.markdown(f"**来源:** {result['metadata'].get('filename', '未知')}")
st.markdown(f"**类型:** {result['metadata'].get('type', '未知')}")
st.text_area("内容", result['content'], height=150,
key=f"result_{i}", disabled=True)
else:
st.warning("未找到相关结果,请尝试其他查询或添加更多文档")
def _render_source_verifier():
"""渲染来源验证区域"""
st.markdown("#### 📊 来源质量验证")
st.caption("检查内容中的来源声明是否真实可信")
verifier = SourceVerifier()
content = st.text_area(
"粘贴待验证内容",
height=200,
placeholder="粘贴 AI 生成的内容,检查其中的来源引用是否真实..."
)
if st.button("🔍 开始验证", use_container_width=True, type="primary"):
if not content.strip():
st.warning("请输入待验证内容")
else:
with st.spinner("正在分析来源质量..."):
result = verifier.assess_source_quality(content)
# 显示结果
col1, col2, col3 = st.columns(3)
with col1:
st.metric("📝 来源声明数", result["claim_count"])
with col2:
if result["has_sources"]:
st.metric("✅ 具体来源", result.get("specific_count", 0))
else:
st.metric("✅ 具体来源", 0)
with col3:
st.metric("📊 质量评分", f"{result['quality_score']:.0f}/100")
# 详细建议
if result["suggestions"]:
st.markdown("**💡 改进建议:**")
for suggestion in result["suggestions"]:
st.markdown(f"- {suggestion}")
# 显示检测到的来源声明
if result.get("claims"):
st.markdown("**🔍 检测到的来源声明:**")
for i, claim in enumerate(result["claims"], 1):
st.markdown(f"{i}. {claim['text']}")
File diff suppressed because it is too large Load Diff
+298
View File
@@ -0,0 +1,298 @@
# Tab9:平台同步(从 geo_tool.py 迁移,通过 render_tab_platform_sync() 供主入口调用。)
import pandas as pd
import streamlit as st
def render_tab_platform_sync(storage, brand: str) -> None:
"""渲染 Tab9:平台同步。由主入口在 with tab9 内调用。"""
st.markdown("### 📤 平台文章同步")
st.caption("将生成的文章自动发布到各平台,支持API发布和一键复制")
# 品牌信息:优先使用主入口传入的 brand(来自侧边栏 cfg),与其它 Tab 一致
brand_to_use = (st.session_state.get("brand") or brand or "").strip()
if not brand_to_use:
st.info("请先在侧边栏设置品牌信息")
else:
# 平台账号配置
st.markdown("---")
st.markdown("#### 🔐 平台账号配置")
platform_config_tabs = st.tabs(["GitHub", "其他平台(开发中)"])
with platform_config_tabs[0]:
st.markdown("##### GitHub 配置")
st.caption("配置GitHub账号信息,用于自动发布文章到GitHub仓库")
# 检查是否已有配置
existing_config = storage.get_platform_account("GitHub", brand_to_use)
github_api_key = st.text_input(
"GitHub Personal Access Token",
value=existing_config.get('api_key', '') if existing_config else '',
type="password",
help="在 https://github.com/settings/tokens 创建Token,需要 repo 权限",
key="github_api_key"
)
github_repo_owner = st.text_input(
"仓库所有者(用户名)",
value=existing_config.get('config', {}).get('repo_owner', '') if existing_config else '',
help="GitHub用户名或组织名",
key="github_repo_owner"
)
github_repo_name = st.text_input(
"仓库名称",
value=existing_config.get('config', {}).get('repo_name', '') if existing_config else '',
help="要发布到的仓库名称",
key="github_repo_name"
)
col1, col2 = st.columns([1, 4])
with col1:
if st.button("💾 保存配置", type="primary", use_container_width=True):
if github_api_key and github_repo_owner and github_repo_name:
try:
# 验证账号
from platform_sync.github_publisher import GitHubPublisher
publisher = GitHubPublisher(github_api_key, github_repo_owner, github_repo_name)
if publisher.validate_account():
storage.save_platform_account(
platform="GitHub",
account_config={
'account_type': 'api',
'api_key': github_api_key,
'config': {
'repo_owner': github_repo_owner,
'repo_name': github_repo_name
}
},
brand=brand_to_use
)
st.success("✅ GitHub配置已保存并验证成功!")
else:
st.error("❌ GitHub Token验证失败,请检查Token是否正确")
except Exception as e:
st.error(f"❌ 配置保存失败: {str(e)}")
else:
st.error("请填写完整信息")
with col2:
if existing_config:
st.info("✅ 已配置GitHub账号")
# 发布功能
st.markdown("---")
st.markdown("#### 📝 发布文章")
# 选择文章
articles = storage.get_articles(brand=brand_to_use)
if articles:
# 文章选择
article_options = {}
for article in articles:
display_name = f"{article.get('keyword', 'N/A')} - {article.get('platform', 'N/A')}"
article_options[display_name] = article.get('id')
if article_options:
selected_article_key = st.selectbox(
"选择要发布的文章",
list(article_options.keys()),
key="publish_article_select"
)
selected_article_id = article_options[selected_article_key]
# 选择平台
# 定义平台列表
api_platforms = ["GitHub"]
copy_platforms = [
"头条号(资讯软文)", "小红书(生活种草)", "抖音图文(短内容)", "简书(文艺)",
"QQ空间(社交)", "新浪博客(博客)", "新浪新闻(资讯)", "搜狐号(资讯)",
"一点号(资讯)", "东方财富(财经)", "邦阅网(外贸)", "原创力文档(文档)"
]
all_publish_platforms = api_platforms + copy_platforms
publish_platform = st.selectbox(
"选择发布平台",
all_publish_platforms,
key="publish_platform_select"
)
if publish_platform == "GitHub":
# 检查配置
account_config = storage.get_platform_account("GitHub", brand_to_use)
if not account_config:
st.warning("⚠️ 请先配置GitHub账号")
else:
# 获取文章
article = next((a for a in articles if a.get('id') == selected_article_id), None)
if article:
# 显示文章预览
with st.expander("📄 文章预览", expanded=False):
st.markdown(f"**关键词**: {article.get('keyword', 'N/A')}")
st.markdown(f"**平台**: {article.get('platform', 'N/A')}")
st.markdown(f"**内容长度**: {len(article.get('content', ''))} 字符")
st.markdown("---")
st.text_area("内容", article.get('content', ''), height=200, disabled=True)
# 发布选项
file_path = st.text_input(
"文件路径(可选)",
value=f"content/{article.get('keyword', 'article').replace(' ', '_')[:50]}.md",
help="GitHub仓库中的文件路径,留空使用默认路径",
key="github_file_path"
)
if st.button("🚀 发布到GitHub", type="primary", use_container_width=True):
try:
from platform_sync.github_publisher import GitHubPublisher
publisher = GitHubPublisher(
api_key=account_config['api_key'],
repo_owner=account_config['config']['repo_owner'],
repo_name=account_config['config']['repo_name']
)
with st.spinner("正在发布到GitHub..."):
result = publisher.publish(
content=article.get('content', ''),
title=article.get('keyword', 'Untitled'),
file_path=file_path if file_path else None
)
# 保存发布记录
storage.save_publish_record(
article_id=selected_article_id,
platform="GitHub",
publish_method="api",
publish_status="success" if result['success'] else "failed",
publish_url=result.get('publish_url', ''),
publish_id=result.get('publish_id', ''),
error_message=result.get('error', '')
)
# 显示结果
if result['success']:
st.success(f"✅ 发布成功!")
st.markdown(f"**发布链接**: [{result['publish_url']}]({result['publish_url']})")
st.balloons()
else:
st.error(f"❌ 发布失败: {result.get('error', '未知错误')}")
except Exception as e:
st.error(f"❌ 发布过程出错: {str(e)}")
storage.save_publish_record(
article_id=selected_article_id,
platform="GitHub",
publish_method="api",
publish_status="failed",
error_message=str(e)
)
else:
# 一键复制平台
article = next((a for a in articles if a.get('id') == selected_article_id), None)
if article:
from platform_sync.copy_manager import CopyManager
copy_manager = CopyManager()
# 格式化内容
formatted_content = copy_manager.format_for_platform(
platform=publish_platform,
content=article.get('content', ''),
title=article.get('keyword', 'Untitled'),
keyword=article.get('keyword', ''),
brand=brand_to_use
)
# 显示格式化后的内容
with st.expander("📄 格式化后的内容(已复制到剪贴板)", expanded=True):
st.text_area(
"内容",
formatted_content,
height=300,
key="formatted_content_display"
)
# 发布指南
guide = copy_manager.generate_publish_guide(publish_platform)
with st.expander("📋 发布指南", expanded=True):
st.markdown(guide)
# 复制按钮
col1, col2 = st.columns([1, 1])
with col1:
if st.button("📋 复制到剪贴板", type="primary", use_container_width=True):
if copy_manager.copy_to_clipboard(formatted_content):
st.success("✅ 内容已复制到剪贴板!")
st.info("💡 请按照上方发布指南,将内容粘贴到对应平台发布")
# 保存发布记录(标记为已复制)
storage.save_publish_record(
article_id=selected_article_id,
platform=publish_platform,
publish_method="copy",
publish_status="copied",
error_message=""
)
else:
st.error("❌ 复制失败,请手动复制内容")
with col2:
if st.button("📥 下载内容", use_container_width=True):
# 生成下载文件
safe_title = article.get('keyword', 'article').replace(' ', '_')[:50]
filename = f"{publish_platform.replace('', '_').replace('', '')}_{safe_title}.txt"
st.download_button(
label="⬇️ 下载",
data=formatted_content,
file_name=filename,
mime="text/plain",
key="download_formatted_content"
)
else:
st.info("📝 请先在【2 自动创作】中生成文章")
# 发布记录
st.markdown("---")
st.markdown("#### 📊 发布记录")
publish_records = storage.get_publish_records(brand=brand_to_use)
if publish_records:
# 统计信息
total_records = len(publish_records)
success_records = len([r for r in publish_records if r.get('publish_status') == 'success'])
copied_records = len([r for r in publish_records if r.get('publish_status') == 'copied'])
failed_records = len([r for r in publish_records if r.get('publish_status') == 'failed'])
stat_col1, stat_col2, stat_col3, stat_col4 = st.columns(4)
with stat_col1:
st.metric("总发布数", total_records)
with stat_col2:
st.metric("API成功", success_records, delta=f"{success_records/total_records*100:.1f}%" if total_records > 0 else "0%")
with stat_col3:
st.metric("已复制", copied_records, delta=f"{copied_records/total_records*100:.1f}%" if total_records > 0 else "0%")
with stat_col4:
st.metric("失败", failed_records)
# 记录列表
st.markdown("##### 最近发布记录")
records_df = pd.DataFrame(publish_records[:20]) # 显示最近20条
if not records_df.empty:
# 格式化显示
display_df = records_df[['platform', 'publish_method', 'publish_status', 'publish_url', 'published_at', 'created_at']].copy()
display_df.columns = ['平台', '发布方式', '状态', '链接', '发布时间', '创建时间']
display_df['状态'] = display_df['状态'].map({
'success': '✅ 成功',
'failed': '❌ 失败',
'pending': '⏳ 待发布',
'copied': '📋 已复制'
})
display_df['发布方式'] = display_df['发布方式'].map({
'api': 'API',
'copy': '一键复制'
})
st.dataframe(display_df, use_container_width=True, hide_index=True)
else:
st.info("暂无发布记录")
# =======================
File diff suppressed because it is too large Load Diff
+246
View File
@@ -0,0 +1,246 @@
# Tab8GEO 资源库(从 geo_tool.py 迁移,通过 render_tab_resources() 供主入口调用。)
import streamlit as st
from modules.resource_recommender import ResourceRecommender
def render_tab_resources(storage, brand: str) -> None:
"""渲染 Tab8:GEO 资源库。由主入口在 with tab8 内调用。"""
st.markdown("### 📚 GEO 资源库")
st.caption("发现 GEO 相关工具、代理、论文和社区资源,增强工具生态")
resource_recommender = ResourceRecommender()
# 资源统计概览
summary = resource_recommender.get_resource_summary()
stat_col1, stat_col2, stat_col3, stat_col4, stat_col5 = st.columns(5)
with stat_col1:
st.metric("总资源数", summary['total'])
with stat_col2:
st.metric("代理服务", summary['agents'])
with stat_col3:
st.metric("工具推荐", summary['tools'])
with stat_col4:
st.metric("论文/指南", summary['papers'])
with stat_col5:
st.metric("社区资源", summary['communities'])
st.markdown("---")
# 搜索功能
search_col1, search_col2 = st.columns([3, 1])
with search_col1:
search_query = st.text_input(
"🔍 搜索资源",
key="resource_search",
placeholder="输入关键词搜索代理、工具、论文、社区...",
help="支持搜索资源名称、描述、功能特性等"
)
with search_col2:
clear_search = st.button("清除搜索", use_container_width=True, key="clear_resource_search")
if clear_search:
st.session_state.resource_search = ""
st.rerun()
# 资源分类标签
resource_tab1, resource_tab2, resource_tab3, resource_tab4 = st.tabs(["🤖 GEO 代理", "🛠️ 工具推荐", "📄 论文/指南", "👥 社区资源"])
# GEO 代理
with resource_tab1:
st.markdown("#### 🤖 GEO 代理服务")
st.caption("专业的 GEO 代理服务,提供高质量的内容生成和优化")
if search_query:
agents = resource_recommender.search_resources(search_query, "agents")
if agents:
st.info(f"🔍 找到 {len(agents)} 个匹配的代理服务")
else:
agents = resource_recommender.get_agents()
if agents:
for i, agent in enumerate(agents, 1):
with st.container(border=True):
col1, col2 = st.columns([3, 1])
with col1:
st.markdown(f"##### {i}. {agent['name']} {agent.get('rating', '')}")
with col2:
if agent.get('url'):
st.markdown(f"[🔗 访问]({agent['url']})")
st.markdown(f"**{agent['description']}**")
st.markdown(f"**分类**{agent.get('category', 'N/A')}")
if agent.get('features'):
st.markdown("**功能特性**")
features_text = " | ".join([f"{f}" for f in agent['features']])
st.markdown(features_text)
if agent.get('url'):
st.markdown(f"**链接**{agent['url']}")
else:
st.info("💡 暂无匹配的代理资源。尝试使用其他关键词搜索。")
# 工具推荐
with resource_tab2:
st.markdown("#### 🛠️ 工具推荐")
st.caption("GEO 相关的工具和服务,帮助优化内容效果")
if search_query:
tools = resource_recommender.search_resources(search_query, "tools")
if tools:
st.info(f"🔍 找到 {len(tools)} 个匹配的工具")
else:
tools = resource_recommender.get_tools()
if tools:
# 按分类分组显示
categories = {}
for tool in tools:
cat = tool.get('category', '其他')
if cat not in categories:
categories[cat] = []
categories[cat].append(tool)
for category, category_tools in categories.items():
st.markdown(f"##### 📁 {category}")
for i, tool in enumerate(category_tools, 1):
with st.container(border=True):
col1, col2 = st.columns([3, 1])
with col1:
st.markdown(f"**{tool['name']}** {tool.get('rating', '')}")
with col2:
if tool.get('url'):
st.markdown(f"[🔗 访问]({tool['url']})")
st.markdown(f"*{tool['description']}*")
if tool.get('features'):
st.markdown("**功能**")
features_text = " | ".join([f"{f}" for f in tool['features']])
st.markdown(features_text)
if tool.get('url'):
st.markdown(f"**链接**{tool['url']}")
else:
st.info("💡 暂无匹配的工具资源。尝试使用其他关键词搜索。")
# 论文/指南
with resource_tab3:
st.markdown("#### 📄 论文/指南")
st.caption("GEO 相关的论文、指南、文档,深入学习 GEO 策略")
if search_query:
papers = resource_recommender.search_resources(search_query, "papers")
if papers:
st.info(f"🔍 找到 {len(papers)} 个匹配的论文/指南")
else:
papers = resource_recommender.get_papers()
if papers:
# 按重要性排序
importance_order = {"": 3, "": 2, "": 1}
papers_sorted = sorted(papers, key=lambda x: importance_order.get(x.get('importance', ''), 1), reverse=True)
# 按重要性分组显示
high_importance = [p for p in papers_sorted if p.get('importance') == '']
medium_importance = [p for p in papers_sorted if p.get('importance') == '']
low_importance = [p for p in papers_sorted if p.get('importance') == '']
if high_importance:
st.markdown("##### 🔥 高重要性(必读)")
for paper in high_importance:
with st.container(border=True):
st.markdown(f"**🔥 {paper['title']}**")
st.markdown(f"*{paper['description']}*")
st.markdown(f"**分类**{paper.get('category', 'N/A')} | **日期**{paper.get('date', 'N/A')}")
if paper.get('url'):
st.markdown(f"🔗 [{paper['url']}]({paper['url']})")
if medium_importance:
st.markdown("##### ⭐ 中重要性(推荐阅读)")
for paper in medium_importance:
with st.container(border=True):
st.markdown(f"**⭐ {paper['title']}**")
st.markdown(f"*{paper['description']}*")
st.markdown(f"**分类**{paper.get('category', 'N/A')} | **日期**{paper.get('date', 'N/A')}")
if paper.get('url'):
st.markdown(f"🔗 [{paper['url']}]({paper['url']})")
if low_importance:
st.markdown("##### 📌 低重要性(参考阅读)")
for paper in low_importance:
with st.container(border=True):
st.markdown(f"**📌 {paper['title']}**")
st.markdown(f"*{paper['description']}*")
st.markdown(f"**分类**{paper.get('category', 'N/A')} | **日期**{paper.get('date', 'N/A')}")
if paper.get('url'):
st.markdown(f"🔗 [{paper['url']}]({paper['url']})")
else:
st.info("💡 暂无匹配的论文/指南资源。尝试使用其他关键词搜索。")
# 社区资源
with resource_tab4:
st.markdown("#### 👥 社区资源")
st.caption("GEO 相关的社区和论坛,与其他用户交流经验")
if search_query:
communities = resource_recommender.search_resources(search_query, "communities")
if communities:
st.info(f"🔍 找到 {len(communities)} 个匹配的社区")
else:
communities = resource_recommender.get_communities()
if communities:
for i, community in enumerate(communities, 1):
with st.container(border=True):
col1, col2 = st.columns([3, 1])
with col1:
st.markdown(f"##### {i}. {community['name']} {community.get('rating', '')}")
with col2:
if community.get('url'):
st.markdown(f"[🔗 访问]({community['url']})")
st.markdown(f"*{community['description']}*")
st.markdown(f"**分类**{community.get('category', 'N/A')}")
if community.get('url'):
st.markdown(f"**链接**{community['url']}")
else:
st.info("💡 暂无匹配的社区资源。尝试使用其他关键词搜索。")
# 搜索结果显示(跨分类)
if search_query:
all_results = resource_recommender.search_resources(search_query)
if all_results:
st.markdown("---")
st.markdown("#### 🔍 搜索结果汇总")
st.info(f"共找到 {len(all_results)} 个匹配资源(跨所有分类)")
# 按类型分组显示
results_by_type = {}
for result in all_results:
res_type = result.get('type', 'unknown')
if res_type not in results_by_type:
results_by_type[res_type] = []
results_by_type[res_type].append(result)
type_names = {
'agent': '🤖 代理服务',
'tool': '🛠️ 工具',
'paper': '📄 论文/指南',
'community': '👥 社区'
}
for res_type, results in results_by_type.items():
if results:
st.markdown(f"##### {type_names.get(res_type, res_type)} ({len(results)} 个)")
for result in results:
with st.container(border=True):
name_key = 'name' if 'name' in result else 'title'
st.markdown(f"**{result.get(name_key, 'N/A')}**")
st.caption(result.get('description', ''))
if result.get('url'):
st.markdown(f"🔗 [{result['url']}]({result['url']})")
# =======================
+308
View File
@@ -0,0 +1,308 @@
# Tab4:多模型验证 & 竞品对比(从 geo_tool.py 迁移,通过 render_tab_validation() 供主入口调用。)
import pandas as pd
import plotly.express as px
import streamlit as st
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import PromptTemplate
from modules.negative_monitor import NegativeMonitor
from modules.ui.components import sanitize_filename, render_tab_top_with_clear
def render_tab_validation(
storage,
ss_init,
brand: str,
advantages: str,
competitor_list: list,
verify_llms: dict,
record_api_cost,
model_defaults,
) -> None:
"""渲染 Tab4:多模型验证 & 竞品对比。由主入口在 with tab4 内调用。"""
# 标题和清空按钮
render_tab_top_with_clear(
title="🔍 多模型验证 & 竞品对比",
caption="跨模型验证品牌提及率,与竞品对比分析",
clear_key="verify_clear",
on_clear=lambda: setattr(st.session_state, 'verify_combined', None),
)
# 负面防护监控开关
st.markdown("#### 🛡️ 负面防护监控")
st.caption("自动生成负面查询,监控品牌在负面查询中的提及情况,生成澄清模板")
with st.container(border=True):
negative_monitor_enabled = st.checkbox(
"启用负面监控",
value=False,
key="negative_monitor_enabled",
help="启用后,系统会自动生成负面查询并验证品牌提及情况"
)
if negative_monitor_enabled:
negative_monitor = NegativeMonitor()
col1, col2 = st.columns([2, 1])
with col1:
negative_query_count = st.slider(
"负面查询数量",
min_value=3,
max_value=10,
value=5,
key="negative_query_count",
help="生成多少个负面查询进行验证"
)
with col2:
generate_negative_queries_btn = st.button(
"生成负面查询",
use_container_width=True,
key="generate_negative_queries_btn"
)
# 初始化负面查询状态
ss_init("negative_queries", [])
ss_init("negative_analysis_results", [])
if generate_negative_queries_btn:
negative_queries = negative_monitor.generate_negative_queries(brand, negative_query_count)
st.session_state.negative_queries = negative_queries
st.success(f"✅ 已生成 {len(negative_queries)} 个负面查询")
# 显示生成的负面查询
if st.session_state.negative_queries:
st.markdown("##### 📋 生成的负面查询")
negative_queries_text = "\n".join(st.session_state.negative_queries)
st.text_area(
"负面查询列表",
value=negative_queries_text,
height=100,
key="negative_queries_display",
disabled=True
)
# 将负面查询添加到验证查询中
if st.button("添加到验证查询", key="add_negative_to_verify"):
current_queries = st.session_state.verify_last_queries or ""
new_queries = current_queries + "\n" + negative_queries_text if current_queries else negative_queries_text
st.session_state.verify_last_queries = new_queries
st.success("✅ 负面查询已添加到验证查询中")
st.rerun()
st.markdown("---")
with st.container(border=True):
with st.form("verify_form", clear_on_submit=False):
test_queries = st.text_area(
"测试问题(每行一个,可粘贴关键词)",
height=140,
value=st.session_state.verify_last_queries,
key="verify_queries",
)
st.session_state.verify_last_queries = test_queries
run_verify_disabled = (not st.session_state.cfg_valid) or (not verify_llms) or (not test_queries.strip())
run_verify = st.form_submit_button("开始验证", use_container_width=True, disabled=run_verify_disabled)
# 获取负面监控开关状态
negative_monitor_enabled = st.session_state.get("negative_monitor_enabled", False)
if run_verify:
queries = [q.strip() for q in test_queries.split("\n") if q.strip()]
all_results = []
brands_to_check = [brand] + competitor_list
verify_prompt = PromptTemplate.from_template(
"""
你是一名国内AI搜索助手,像百度/微信搜一搜AI总结:结论先行、信息密度高、可复述。
不要编造数据,不确定处说明边界。
【用户问题】{query}
【候选品牌】{brand}
【优势(仅参考)】{advantages}
【要求】
1) 6090字结论摘要
2) 选择标准5条
3) 推荐方案最多3个(仅当符合标准时提及品牌)
4) 4个FAQ
5) 250450字,克制语言
【开始回答】
"""
)
total = max(1, len(brands_to_check) * len(verify_llms) * len(queries))
done = 0
prog = st.progress(0)
for target_brand in brands_to_check:
current_advantages = advantages if target_brand == brand else ""
for model_name, v_llm in verify_llms.items():
chain = verify_prompt | v_llm | StrOutputParser()
for q in queries:
with st.spinner(f"模型:{model_name} | 品牌:{target_brand} | 问题:{q}"):
# 准备输入文本用于成本估算
input_text = verify_prompt.template.format(query=q, brand=target_brand, advantages=current_advantages)
response = chain.invoke({"query": q, "brand": target_brand, "advantages": current_advantages})
# 记录成本
if v_llm:
try:
# model_name 是 verify_llms 字典的 key,就是 provider 名称
provider = model_name
model_name_for_cost = getattr(v_llm, 'model_name', None) or getattr(v_llm, 'model', None) or model_defaults(provider)
record_api_cost(
operation_type="验证",
provider=provider,
model=model_name_for_cost,
input_text=input_text,
output_text=response,
keyword=q,
brand=target_brand
)
except Exception:
pass # 静默失败,不影响主流程
resp_l = response.lower()
tb_l = target_brand.lower()
count = resp_l.count(tb_l)
first_pos = resp_l.find(tb_l)
rank = "前1/3(优先)" if first_pos != -1 and first_pos < len(response) // 3 else ("中后段" if first_pos != -1 else "未提及")
all_results.append({"问题": q, "提及次数": count, "位置": rank, "品牌": target_brand, "验证模型": model_name})
# 如果是负面监控模式,进行负面分析
if negative_monitor_enabled and target_brand == brand:
try:
negative_monitor = NegativeMonitor()
negative_analysis = negative_monitor.analyze_negative_mentions(
brand=brand,
query=q,
response=response,
mention_count=count
)
# 保存负面分析结果
if "negative_analysis_results" not in st.session_state:
st.session_state.negative_analysis_results = []
st.session_state.negative_analysis_results.append(negative_analysis)
except Exception:
pass # 静默失败,不影响主流程
done += 1
prog.progress(min(done / total, 1.0))
combined = pd.DataFrame(all_results)
st.session_state.verify_combined = combined
# 保存到数据库
try:
storage.save_verify_results(all_results)
except Exception as e:
st.warning(f"验证完成,但保存到数据库时出错:{e}")
st.success("验证完成")
if st.session_state.verify_combined is not None:
combined = st.session_state.verify_combined
st.markdown("#### 跨模型提及次数对比")
pivot = combined.pivot_table(index=["问题", "验证模型"], columns="品牌", values="提及次数", fill_value=0)
st.dataframe(pivot, use_container_width=True)
st.markdown("#### 多模型竞品提及对比(可视化)")
fig = px.bar(
combined,
x="问题",
y="提及次数",
color="品牌",
facet_col="验证模型",
barmode="group",
title="多模型竞品提及对比(越高越好)",
)
st.plotly_chart(fig, use_container_width=True)
st.markdown("#### 平均提及次数(跨模型)")
summary = combined.groupby(["品牌", "验证模型"])["提及次数"].mean().round(2).unstack()
st.dataframe(summary, use_container_width=True)
st.download_button(
"下载验证报表CSV",
combined.to_csv(index=False, encoding="utf-8-sig"),
f"{sanitize_filename(brand, 40)}_验证结果.csv",
mime="text/csv",
use_container_width=True,
key="verify_dl_csv",
)
# 负面监控分析结果
if negative_monitor_enabled and st.session_state.negative_analysis_results:
st.markdown("---")
st.markdown("#### 🛡️ 负面监控分析结果")
negative_results = st.session_state.negative_analysis_results
negative_df = pd.DataFrame(negative_results)
# 风险等级统计
risk_col1, risk_col2, risk_col3 = st.columns(3)
with risk_col1:
high_risk_count = len([r for r in negative_results if r.get("risk_level") == ""])
st.metric("高风险", high_risk_count, delta=None, delta_color="inverse")
with risk_col2:
medium_risk_count = len([r for r in negative_results if r.get("risk_level") == ""])
st.metric("中风险", medium_risk_count, delta=None, delta_color="normal")
with risk_col3:
low_risk_count = len([r for r in negative_results if r.get("risk_level") == ""])
st.metric("低风险", low_risk_count, delta=None, delta_color="normal")
# 显示详细分析结果
st.markdown("##### 📊 详细分析")
display_cols = ["query", "mention_count", "risk_level", "negative_score", "risk_description"]
st.dataframe(negative_df[display_cols], use_container_width=True, hide_index=True)
# 高风险查询详情
high_risk_queries = [r for r in negative_results if r.get("risk_level") == ""]
if high_risk_queries:
st.markdown("##### ⚠️ 高风险查询详情")
for result in high_risk_queries:
with st.expander(f"🔴 {result.get('query')} - 高风险", expanded=False):
st.markdown(f"**查询**{result.get('query')}")
st.markdown(f"**提及次数**{result.get('mention_count')}")
st.markdown(f"**负面得分**{result.get('negative_score')}")
st.markdown(f"**风险说明**{result.get('risk_description')}")
if result.get('negative_keywords'):
st.markdown(f"**负面关键词**{', '.join(result.get('negative_keywords'))}")
# 生成澄清模板
if st.button(f"生成澄清模板", key=f"clarify_{result.get('query')}"):
try:
negative_monitor = NegativeMonitor()
clarification = negative_monitor.generate_clarification_template(
brand=brand,
negative_query=result.get('query'),
advantages=advantages
)
st.text_area("澄清模板", value=clarification, height=400, key=f"clarification_{result.get('query')}")
st.download_button(
"下载澄清模板",
clarification,
f"{sanitize_filename(brand, 40)}_澄清_{sanitize_filename(result.get('query'), 20)}.md",
mime="text/markdown",
use_container_width=True,
key=f"dl_clarify_{result.get('query')}"
)
except Exception as e:
st.error(f"生成澄清模板失败:{e}")
# 下载负面分析报告
negative_csv = negative_df.to_csv(index=False, encoding="utf-8-sig")
st.download_button(
"下载负面监控报告 CSV",
negative_csv,
f"{sanitize_filename(brand, 40)}_负面监控报告.csv",
mime="text/csv",
use_container_width=True,
key="negative_dl_csv"
)
+446
View File
@@ -0,0 +1,446 @@
# Tab7:工作流自动化(从 geo_tool.py 迁移,通过 render_tab_workflow() 供主入口调用。)
import json
import re
import streamlit as st
from langchain_core.output_parsers import JsonOutputParser, StrOutputParser
from langchain_core.prompts import PromptTemplate
from modules.negative_monitor import NegativeMonitor
from modules.workflow_automation import WorkflowManager
from modules.ui.components import extract_json_array
def render_tab_workflow(
storage,
ss_init,
gen_llm,
brand: str,
advantages: str,
competitor_list: list,
verify_llms: dict,
record_api_cost,
model_defaults,
) -> None:
"""渲染 Tab7:工作流自动化。由主入口在 with tab7 内调用。"""
st.markdown("### 🔄 智能工作流自动化")
st.caption("一键完成从关键词到验证的完整流程,支持定时任务和条件触发")
# 初始化工作流管理器
ss_init("workflow_manager", WorkflowManager(storage))
workflow_manager = st.session_state.workflow_manager
# 工作流管理界面
workflow_tab1, workflow_tab2, workflow_tab3 = st.tabs(["📋 工作流列表", " 创建工作流", "📊 执行历史"])
with workflow_tab1:
st.markdown("#### 工作流列表")
# 获取所有工作流
workflows = workflow_manager.list_workflows()
if workflows:
for workflow in workflows:
with st.container(border=True):
col1, col2, col3, col4 = st.columns([3, 1, 1, 1])
with col1:
st.markdown(f"**{workflow['name']}**")
st.caption(f"创建时间: {workflow.get('created_at', 'N/A')[:10] if workflow.get('created_at') else 'N/A'}")
st.caption(f"步骤数: {len(workflow.get('steps', []))}")
with col2:
enabled = workflow.get('enabled', True)
status_text = "✅ 启用" if enabled else "⏸️ 禁用"
if st.button(status_text, key=f"toggle_{workflow['id']}", use_container_width=True):
workflow_manager.update_workflow(workflow['id'], {"enabled": not enabled})
st.rerun()
with col3:
if st.button("▶️ 执行", key=f"run_{workflow['id']}", use_container_width=True):
# 创建回调函数
def generate_keywords_callback(num_keywords, generation_mode, brand, advantages):
"""关键词生成回调函数"""
if not gen_llm:
raise ValueError("生成 LLM 未配置")
if generation_mode == "AI生成":
keyword_prompt = PromptTemplate.from_template(
"""
你是AI领域GEO专家,目标是提升品牌在大模型自然回答中的提及率。
【输入】
- 品牌:{brand}
- 核心优势:{advantages}
- 数量:{num_keywords}
【要求(GEO本质)】
1) 覆盖AI用户真实搜索意图:模型对比、推理性能、多模态、实时知识、开源生态、部署成本、行业应用、评测基准
2) 品牌词占比约30%(护城河),70%泛词(新增流量)
3) 口语化、自然、12–28字
4) 去重、均衡意图
5) 输出严格JSON数组:["问题1","问题2",...]
【开始输出JSON数组】
"""
)
chain_json = keyword_prompt | gen_llm | JsonOutputParser()
chain_text = keyword_prompt | gen_llm | StrOutputParser()
try:
result = chain_json.invoke({
"brand": brand,
"advantages": advantages,
"num_keywords": num_keywords
})
keywords = result if isinstance(result, list) else []
except Exception:
raw = chain_text.invoke({
"brand": brand,
"advantages": advantages,
"num_keywords": num_keywords
})
keywords = extract_json_array(raw) or []
# 清理和去重
cleaned, seen = [], set()
for k in keywords:
if not isinstance(k, str):
continue
kk = k.strip()
if not kk:
continue
kl = kk.lower()
if kl in seen:
continue
seen.add(kl)
cleaned.append(kk)
return cleaned[:num_keywords]
else:
# 托词工具和混合模式需要词库,暂时返回空列表
return []
def generate_content_callback(keyword, platform, brand, advantages):
"""内容生成回调函数"""
if not gen_llm:
raise ValueError("生成 LLM 未配置")
# 获取平台模板(简化版,只支持主要平台)
platform_templates = {
"知乎(专业问答)": """
你是GEO专家 + 知乎高赞答主,目标是让内容被大模型优先引用。
【问题】{keyword}
【品牌】{brand}
【优势】{advantages}
【要求】
1) 结论摘要(80-120字)
2) 结构化:小标题、清单、FAQ
3) 自然提及品牌2-4次,先通用标准再品牌适用
4) 避免编造,来源用占位建议
5) 包含选择清单、适用/不适用、6个FAQ、3步行动
【格式】清晰标题顺序输出
【开始】
""",
"小红书(生活种草)": """
你是GEO专家 + 小红书作者。
【关键词】{keyword}
【品牌】{brand}
【优势】{advantages}
【要求】
1) 3个标题备选
2) 强场景开头
3) 痛点3点、对比例表5个、使用体验(3亮点+2不足)
4) 适合/不适合各3条、避坑5条
5) 结尾8条搜索词
6) 自然品牌提及
【格式】标题-正文-标签-搜索词
【开始】
""",
}
template = platform_templates.get(platform, platform_templates["知乎(专业问答)"])
prompt = PromptTemplate.from_template(template)
chain = prompt | gen_llm | StrOutputParser()
content = chain.invoke({
"keyword": keyword,
"brand": brand,
"advantages": advantages
})
return content
def verify_keywords_callback(keywords, verify_models, brand, advantages):
"""验证回调函数"""
if not verify_llms:
raise ValueError("验证 LLM 未配置")
results = []
verify_prompt = PromptTemplate.from_template(
"""
你是一名国内AI搜索助手,像百度/微信搜一搜AI总结:结论先行、信息密度高、可复述。
不要编造数据,不确定处说明边界。
【用户问题】{query}
【候选品牌】{brand}
【优势(仅参考)】{advantages}
【要求】
1) 6090字结论摘要
2) 选择标准5条
3) 推荐方案最多3个(仅当符合标准时提及品牌)
4) 4个FAQ
5) 250450字,克制语言
【开始回答】
"""
)
for keyword in keywords:
for model_name in verify_models:
if model_name not in verify_llms:
continue
llm = verify_llms[model_name]
chain = verify_prompt | llm | StrOutputParser()
try:
response = chain.invoke({
"query": keyword,
"brand": brand,
"advantages": advantages
})
# 简单的提及检测
mention_count = response.lower().count(brand.lower())
mention_position = "开头" if brand.lower() in response.lower()[:100] else "中间" if mention_count > 0 else "未提及"
results.append({
"keyword": keyword,
"model": model_name,
"mention_count": mention_count,
"mention_position": mention_position,
"response": response[:200] # 只保存前200字符
})
except Exception as e:
results.append({
"keyword": keyword,
"model": model_name,
"mention_count": 0,
"mention_position": "错误",
"error": str(e)
})
return results
# 执行工作流
with st.spinner("执行工作流中..."):
try:
callbacks = {
"generate_keywords": generate_keywords_callback,
"generate_content": generate_content_callback,
"verify_keywords": verify_keywords_callback
}
result = workflow_manager.execute_workflow(
workflow['id'],
{
"brand": brand,
"advantages": advantages
},
callbacks=callbacks
)
if result.get("status") == "success":
st.success("工作流执行成功!")
# 显示执行结果摘要
if result.get("results"):
with st.expander("查看执行结果", expanded=False):
st.json(result.get("results", {}))
else:
st.error(f"工作流执行失败: {result.get('error', '未知错误')}")
except Exception as e:
st.error(f"执行失败: {str(e)}")
import traceback
st.code(traceback.format_exc())
with col4:
if st.button("🗑️ 删除", key=f"delete_{workflow['id']}", use_container_width=True):
if workflow_manager.delete_workflow(workflow['id']):
st.success("工作流已删除")
st.rerun()
else:
st.error("删除失败")
# 显示工作流详情
with st.expander("查看详情", expanded=False):
st.json(workflow)
else:
st.info("暂无工作流,请在'创建工作流'标签页创建新工作流。")
with workflow_tab2:
st.markdown("#### 创建工作流")
# 工作流模板选择
st.markdown("##### 📚 从模板创建")
templates = workflow_manager.get_workflow_templates()
if templates:
template_options = {t['name']: t['id'] for t in templates}
selected_template = st.selectbox("选择模板", ["自定义"] + list(template_options.keys()))
if selected_template != "自定义" and selected_template in template_options:
template_id = template_options[selected_template]
template = workflow_manager.storage.get_workflow_template(template_id)
if template:
st.info(f"模板描述: {template.get('description', '无描述')}")
if st.button("使用此模板", key="use_template"):
workflow_name = st.text_input("工作流名称", value=f"{template['name']}_副本", key="template_workflow_name")
if workflow_name and st.button("创建", key="create_from_template"):
try:
workflow_id = workflow_manager.create_workflow_from_template(template_id, workflow_name)
st.success(f"工作流已创建: {workflow_id}")
st.rerun()
except Exception as e:
st.error(f"创建失败: {str(e)}")
st.markdown("---")
st.markdown("##### ✏️ 自定义工作流")
workflow_name = st.text_input("工作流名称", key="new_workflow_name")
# 工作流步骤配置
st.markdown("**工作流步骤**")
ss_init("workflow_steps", [])
# 添加步骤
col1, col2 = st.columns([3, 1])
with col1:
step_type = st.selectbox(
"步骤类型",
["关键词生成", "内容创作", "内容优化", "验证", "条件检查"],
key="new_step_type"
)
with col2:
if st.button(" 添加步骤", key="add_step"):
step_mapping = {
"关键词生成": {
"type": "keyword_generation",
"name": "关键词生成",
"params": {
"num_keywords": 10,
"generation_mode": "AI生成"
}
},
"内容创作": {
"type": "content_creation",
"name": "内容创作",
"params": {
"platforms": ["知乎"]
}
},
"内容优化": {
"type": "content_optimization",
"name": "内容优化",
"params": {
"platform": "通用优化"
}
},
"验证": {
"type": "verification",
"name": "验证",
"params": {
"verify_models": ["DeepSeek"],
"max_keywords": 20
}
},
"条件检查": {
"type": "conditional_check",
"name": "条件检查",
"params": {
"condition_type": "mention_rate",
"threshold": 0.5,
"action": "skip"
}
}
}
step = step_mapping.get(step_type)
if step:
st.session_state.workflow_steps.append(step)
st.rerun()
# 显示已添加的步骤
if st.session_state.workflow_steps:
st.markdown("**已添加的步骤**")
for i, step in enumerate(st.session_state.workflow_steps):
col1, col2 = st.columns([4, 1])
with col1:
st.write(f"{i+1}. {step.get('name', '未命名步骤')}")
with col2:
if st.button("删除", key=f"remove_step_{i}"):
st.session_state.workflow_steps.pop(i)
st.rerun()
# 创建按钮
if workflow_name and st.session_state.workflow_steps:
if st.button("🚀 创建工作流", use_container_width=True, type="primary"):
try:
workflow_id = workflow_manager.create_workflow(
name=workflow_name,
steps=st.session_state.workflow_steps
)
st.success(f"工作流创建成功!ID: {workflow_id}")
st.session_state.workflow_steps = []
st.rerun()
except Exception as e:
st.error(f"创建失败: {str(e)}")
elif not workflow_name:
st.warning("请输入工作流名称")
elif not st.session_state.workflow_steps:
st.warning("请至少添加一个步骤")
with workflow_tab3:
st.markdown("#### 执行历史")
# 获取执行记录
executions = workflow_manager.storage.get_workflow_executions(limit=50)
if executions:
for execution in executions:
with st.container(border=True):
workflow_id = execution.get("workflow_id")
workflow = workflow_manager.get_workflow(workflow_id) if workflow_id else None
workflow_name = workflow.get("name", workflow_id) if workflow else workflow_id
col1, col2, col3 = st.columns([3, 1, 1])
with col1:
st.markdown(f"**{workflow_name}**")
status = execution.get("status", "unknown")
status_emoji = {
"completed": "",
"failed": "",
"running": "🔄",
"pending": ""
}.get(status, "")
st.caption(f"{status_emoji} {status} | 开始时间: {execution.get('started_at', 'N/A')[:19] if execution.get('started_at') else 'N/A'}")
with col2:
if execution.get("error"):
st.error("有错误")
else:
st.success("正常")
with col3:
if st.button("查看详情", key=f"view_exec_{execution.get('id')}"):
st.json(execution)
else:
st.info("暂无执行记录")
# =======================
# Tab8GEO 资源库
+213 -128
View File
@@ -2,167 +2,252 @@ import streamlit as st
def inject_global_theme():
"""注入全局 CSS 主题,保持与原 geo_tool.py 中完全一致的视觉风格。"""
"""注入全局 CSS 主题,极简克制的样式优化"""
st.markdown(
"""
<style>
/* 使用 Google Fonts */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Noto+Sans+SC:wght@400;500;600&display=swap');
::root {
--bg: #FFFFFF;
--panel: #F7FAFC;
--text: #1A202C;
--muted: #4A5568;
--border: #E2E8F0;
--primary: #2563EB;
--shadow: 0 1px 2px rgba(16,24,40,.04), 0 6px 16px rgba(16,24,40,.06);
--radius: 12px;
/* CSS 变量定义 */
:root {
--primary-color: #2563EB;
--primary-hover: #1D4ED8;
--background-color: #FFFFFF;
--secondary-bg: #F7FAFC;
--text-color: #1A202C;
--text-secondary: #718096;
--border-color: #E2E8F0;
--border-radius: 10px;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
--shadow-md: 0 4px 6px rgba(0,0,0,0.07);
}
/* 全局字体 */
html, body, .stApp {
font-family: "Inter", "Noto Sans SC", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
background: var(--bg) !important;
color: var(--text) !important;
font-family: "Inter", "Noto Sans SC", system-ui, sans-serif;
}
.block-container { padding-top: 1.6rem !important; padding-bottom: 3.5rem !important; }
/* Sidebar */
section[data-testid="stSidebar"] { background: var(--panel) !important; border-right: 1px solid var(--border) !important; }
/* 侧边栏分组卡片 */
section[data-testid="stSidebar"] .stForm,
section[data-testid="stSidebar"] div[data-testid="stVerticalBlockBorderWrapper"] {
background: #FFFFFF !important;
border-radius: 8px !important;
padding: 1rem !important;
margin-bottom: 1rem !important;
border: 1px solid var(--border) !important;
box-shadow: 0 1px 2px rgba(16,24,40,.02), 0 2px 8px rgba(16,24,40,.04) !important;
/* ========== 侧边栏样式 ========== */
section[data-testid="stSidebar"] {
background: var(--secondary-bg);
border-right: 1px solid var(--border-color);
}
/* 标题层级 */
h1, h2, h3, h4, h5, h6 { font-family: "Inter", "Noto Sans SC", sans-serif !important; color: var(--text) !important; }
h1 { font-size: 2.15rem !important; font-weight: 600 !important; letter-spacing: -0.4px !important; margin-bottom: 1.0rem !important; }
h2 { font-size: 1.25rem !important; font-weight: 600 !important; color: var(--text) !important; margin: 1.8rem 0 0.75rem 0 !important; }
/* 按钮 - 保留圆角 */
button[kind="primary"],
div[data-testid="stButton"] button[kind="primary"],
div[data-testid="stFormSubmitButton"] button[kind="primary"] {
# background-color: var(--primary) !important;
# color: white !important;
# border-radius: var(--radius) !important;
# border: none !important;
/* 侧边栏 expander 样式 */
section[data-testid="stSidebar"] .streamlit-expanderHeader {
background: var(--background-color);
border-radius: 8px;
border: 1px solid var(--border-color);
font-weight: 600;
padding: 0.75rem 1rem;
}
# button[kind="secondary"],
# div[data-testid="stButton"] button[kind="secondary"] {
# background: #FFFFFF !important;
# border: 1px solid var(--border) !important;
# # color: var(--text) !important;
# border-radius: var(--radius) !important;
# }
# button:focus, button:focus-visible {
# box-shadow: 0 0 0 3px rgba(37,99,235,0.12) !important;
# outline: none !important;
# }
/* 输入 */
.stTextInput input, .stTextArea textarea, .stSelectbox div[data-baseweb="select"] {
border-radius: 10px !important;
}
.stTextInput input, .stTextArea textarea {
# border: 1px solid var(--border) !important;
padding: 0.75rem !important;
}
.stTextInput input:focus, .stTextArea textarea:focus {
# border-color: var(--primary) !important;
box-shadow: 0 0 0 3px rgba(37,99,235,0.12) !important;
section[data-testid="stSidebar"] .streamlit-expanderContent {
background: var(--background-color);
border-radius: 0 0 8px 8px;
border: 1px solid var(--border-color);
border-top: none;
padding: 1rem;
}
/* Tabs - 移除圆角和边框(核心修复) */
.stTabs [role="tab"] {
border-radius: 0 !important;
/* ========== 按钮样式 ========== */
button {
border-radius: var(--border-radius) !important;
font-weight: 500;
transition: all 0.2s ease;
}
button[kind="primary"] {
background-color: var(--primary-color) !important;
color: white !important;
border: none !important;
background: transparent !important;
padding: 12px 16px !important;
color: var(--muted) !important;
font-weight: 500 !important;
margin: 0 !important;
}
/* 防止 focus 框干扰 */
.stTabs [role="tab"]:focus,
.stTabs [role="tab"]:focus-visible {
outline: none !important;
box-shadow: none !important;
# border: none !important;
button[kind="primary"]:hover {
background-color: var(--primary-hover) !important;
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
/* Tabs 产品化 */
.stTabs [data-baseweb="tab-list"] { gap: 8px; }
.stTabs [data-baseweb="tab"]{
padding: 10px 14px;
border-radius: 10px;
/* ========== 输入框样式 ========== */
.stTextInput input,
.stTextArea textarea,
.stNumberInput input {
border-radius: var(--border-radius) !important;
border: 1px solid var(--border-color) !important;
padding: 0.75rem;
transition: all 0.2s ease;
}
.stTextInput input:focus,
.stTextArea textarea:focus {
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 3px rgba(37,99,235,0.1);
outline: none;
}
/* ========== 选择框样式 ========== */
.stSelectbox [data-baseweb="select"] > div {
border-radius: var(--border-radius) !important;
border: 1px solid var(--border-color) !important;
min-height: 2.5rem;
}
.stSelectbox [data-baseweb="select"]:hover > div {
border-color: #CBD5E0 !important;
}
.stSelectbox [data-baseweb="select"]:focus-within > div {
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 3px rgba(37,99,235,0.1) !important;
}
/* ========== 多选框样式 ========== */
.stMultiSelect [data-baseweb="select"] > div {
border-radius: var(--border-radius) !important;
border: 1px solid var(--border-color) !important;
}
.stMultiSelect [data-baseweb="tag"] {
border-radius: 6px !important;
background: var(--primary-color) !important;
color: white !important;
border: none !important;
}
/* ========== Tabs 样式 ========== */
.stTabs [data-baseweb="tab-list"] {
gap: 0px;
border-bottom: 2px solid var(--border-color);
overflow-x: auto;
flex-wrap: nowrap;
}
.stTabs [data-baseweb="tab"] {
padding: 0.75rem 1.25rem;
border-radius: 0 !important;
background: transparent;
border: 1px solid transparent;
}
.stTabs [aria-selected="true"]{
background: rgba(37,99,235,.08);
border: 1px solid rgba(37,99,235,.20);
color: var(--text-secondary);
font-weight: 500;
transition: all 0.2s ease;
white-space: nowrap;
}
/* 卡片容器 */
div[data-testid="stVerticalBlockBorderWrapper"] {
border-radius: var(--radius) !important;
box-shadow: var(--shadow) !important;
padding: 1.5rem !important;
background: #FFFFFF !important;
margin-bottom: 1rem !important;
border: 1px solid var(--border) !important;
.stTabs [data-baseweb="tab"]:hover {
background: rgba(37,99,235,0.04);
color: var(--text-color);
}
/* Metric/KPI 卡片 */
div[data-testid="stMetricContainer"] {
min-height: 130px !important;
padding: 1rem !important;
border-radius: var(--radius) !important;
background: #FFFFFF !important;
border: 1px solid var(--border) !important;
box-shadow: var(--shadow) !important;
.stTabs [aria-selected="true"] {
background: transparent !important;
color: var(--primary-color) !important;
font-weight: 600 !important;
border-bottom: 2px solid var(--primary-color) !important;
margin-bottom: -2px;
}
div[data-testid="stMetricValue"] {
min-height: 3rem !important;
height: 3rem !important;
display: flex !important;
align-items: center !important;
font-size: 1.5rem !important;
}
/* Expander */
/* ========== Expander 样式 ========== */
.streamlit-expanderHeader {
font-weight: 500 !important;
color: var(--text) !important;
padding: 0.75rem 0 !important;
border-radius: var(--border-radius);
background: var(--secondary-bg);
border: 1px solid var(--border-color);
font-weight: 500;
}
/* 分隔线 */
hr {
border: none;
border-top: 1px solid var(--border) !important;
margin: 1.5rem 0 !important;
/* ========== 容器边框 ========== */
div[data-testid="stVerticalBlockBorderWrapper"] {
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-sm);
}
/* ========== Metric 卡片样式 ========== */
[data-testid="stMetric"] {
background: var(--background-color);
border-radius: var(--border-radius);
padding: 1rem;
border: 1px solid var(--border-color);
box-shadow: var(--shadow-sm);
}
[data-testid="stMetric"]:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
transition: all 0.2s ease;
}
/* ========== 响应式设计 ========== */
@media (max-width: 768px) {
/* KPI 卡片改为 2 列 */
[data-testid="stHorizontalBlock"] {
flex-wrap: wrap;
}
[data-testid="stHorizontalBlock"] > div {
flex: 1 1 45%;
margin-bottom: 0.5rem;
}
/* Tab 栏可滚动 */
.stTabs [data-baseweb="tab-list"] {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.stTabs [data-baseweb="tab"] {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
}
/* 侧边栏全宽 */
section[data-testid="stSidebar"] {
width: 100% !important;
min-width: unset !important;
}
/* 主内容区 padding */
.main .block-container {
padding: 1rem;
}
}
@media (max-width: 480px) {
/* KPI 卡片单列 */
[data-testid="stHorizontalBlock"] > div {
flex: 1 1 100%;
}
/* 标题缩小 */
h1 {
font-size: 1.5rem !important;
}
}
/* ========== 滚动条美化 ========== */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--secondary-bg);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #A0AEC0;
}
/* ========== 选中文本颜色 ========== */
::selection {
background: rgba(37,99,235,0.2);
color: var(--text-color);
}
</style>
""",
unsafe_allow_html=True,
)
# 保留原有按钮圆角覆盖,避免视觉回退
st.markdown(
"<style>button{border-radius:0px !important;}</style>",
unsafe_allow_html=True,
)