添加产品规格文档并优化项目结构
Made-with: Cursor
This commit is contained in:
+25
-1
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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("💡 请先在【🎯 关键词蒸馏】生成关键词。")
|
||||
|
||||
@@ -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("提示:当您修改品牌名、优势描述或竞品列表后,系统会自动清除旧结果,需要重新分析。")
|
||||
@@ -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}")
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -0,0 +1,246 @@
|
||||
# Tab8:GEO 资源库(从 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']})")
|
||||
|
||||
# =======================
|
||||
@@ -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) 60–90字结论摘要
|
||||
2) 选择标准5条
|
||||
3) 推荐方案最多3个(仅当符合标准时提及品牌)
|
||||
4) 4个FAQ
|
||||
5) 250–450字,克制语言
|
||||
|
||||
【开始回答】
|
||||
"""
|
||||
)
|
||||
|
||||
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"
|
||||
)
|
||||
@@ -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) 60–90字结论摘要
|
||||
2) 选择标准5条
|
||||
3) 推荐方案最多3个(仅当符合标准时提及品牌)
|
||||
4) 4个FAQ
|
||||
5) 250–450字,克制语言
|
||||
|
||||
【开始回答】
|
||||
"""
|
||||
)
|
||||
|
||||
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("暂无执行记录")
|
||||
|
||||
# =======================
|
||||
# Tab8:GEO 资源库
|
||||
+213
-128
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user