(all):增加桌面程序
@@ -1,3 +1,8 @@
|
||||
# Python 打包产物
|
||||
dist_python/
|
||||
build_python/
|
||||
*.spec
|
||||
|
||||
# SQLite 数据库文件
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -1,41 +0,0 @@
|
||||
# Streamlit Secrets 配置文件
|
||||
# 复制此文件为 secrets.toml 并填入真实的 API Key
|
||||
# 注意:secrets.toml 已在 .gitignore 中,不会被提交到仓库
|
||||
|
||||
[api_keys]
|
||||
# DeepSeek API Key(推荐,性价比高)
|
||||
deepseek = ""
|
||||
|
||||
# OpenAI API Key(可选)
|
||||
# openai = ""
|
||||
|
||||
# 通义千问 API Key(可选)
|
||||
# tongyi = ""
|
||||
|
||||
# Groq API Key(可选)
|
||||
# groq = ""
|
||||
|
||||
# Moonshot (Kimi) API Key(可选)
|
||||
# moonshot = ""
|
||||
|
||||
# 豆包 API Key(可选,格式:access_key:secret_key:endpoint_id)
|
||||
# doubao = ""
|
||||
|
||||
# 文心一言 API Key(可选,格式:app_key:app_secret)
|
||||
# wenxin = ""
|
||||
|
||||
# 通义万相 API Key(图片生成,可选)
|
||||
# tongyi_wanxiang = ""
|
||||
|
||||
[app_config]
|
||||
# 品牌名称
|
||||
brand = ""
|
||||
|
||||
# 品牌优势
|
||||
advantages = ""
|
||||
|
||||
# 竞品列表(换行分隔)
|
||||
competitors = ""
|
||||
|
||||
# LLM 温度参数
|
||||
temperature = 0.7
|
||||
@@ -0,0 +1 @@
|
||||
# build_scripts package
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
GEO Tool — 跨平台打包构建脚本
|
||||
|
||||
用法:
|
||||
python build_scripts/build.py # 打包 Python 可执行文件
|
||||
python build_scripts/build.py --clean # 先清理旧构建再打包
|
||||
python build_scripts/build.py tauri # 打包 Python 可执行文件 + 构建 Tauri 桌面应用
|
||||
|
||||
输出:
|
||||
dist_python/geo_tool_app (macOS / Linux)
|
||||
dist_python/geo_tool_app.exe (Windows)
|
||||
src-tauri/target/release/GEO工具 (Tauri 桌面应用)
|
||||
|
||||
依赖:
|
||||
pip install pyinstaller
|
||||
cargo tauri build (for tauri 命令)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ── 项目根目录 ────────────────────────────────────────────────
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
SPEC_FILE = PROJECT_ROOT / "geo_tool_app.spec"
|
||||
DIST_DIR = PROJECT_ROOT / "dist_python"
|
||||
BUILD_DIR = PROJECT_ROOT / "build_python"
|
||||
TAURI_DIR = PROJECT_ROOT / "src-tauri"
|
||||
|
||||
|
||||
def check_prerequisites():
|
||||
"""检查必要工具是否已安装"""
|
||||
# 检查 PyInstaller
|
||||
try:
|
||||
import PyInstaller # noqa: F401
|
||||
except ImportError:
|
||||
print("正在安装 PyInstaller ...")
|
||||
subprocess.check_call(
|
||||
[sys.executable, "-m", "pip", "install", "pyinstaller"],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
)
|
||||
|
||||
# 检查项目依赖是否已安装
|
||||
try:
|
||||
import streamlit # noqa: F401
|
||||
except ImportError:
|
||||
print("正在安装项目依赖 ...")
|
||||
# 优先用 uv,否则 pip install pyproject.toml
|
||||
for installer in ["uv", "pip"]:
|
||||
try:
|
||||
subprocess.check_call(
|
||||
[installer, "sync"] if installer == "uv" else [installer, "install", "."],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
)
|
||||
break
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
continue
|
||||
|
||||
|
||||
def clean_build():
|
||||
"""清理之前的构建产物"""
|
||||
for d in [DIST_DIR, BUILD_DIR]:
|
||||
if d.exists():
|
||||
print(f" 清理 {d} ...")
|
||||
shutil.rmtree(d)
|
||||
|
||||
# 清理 spec 生成的临时目录
|
||||
temp_spec_dir = PROJECT_ROOT / "__pycache__"
|
||||
if temp_spec_dir.exists():
|
||||
shutil.rmtree(temp_spec_dir)
|
||||
|
||||
|
||||
def build_tauri():
|
||||
"""先打包 Python 可执行文件,然后构建 Tauri 桌面应用"""
|
||||
build()
|
||||
print("\n正在构建 Tauri 桌面应用 ...\n")
|
||||
result = subprocess.run(
|
||||
["cargo", "tauri", "build"],
|
||||
cwd=str(TAURI_DIR),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"\n[错误] Tauri 构建失败 (返回码: {result.returncode})")
|
||||
sys.exit(1)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(" Tauri 构建完成!")
|
||||
release_dir = TAURI_DIR / "target" / "release"
|
||||
if sys.platform == "darwin":
|
||||
bundles = list(release_dir.glob("*.dmg")) + list((release_dir / "bundle" / "dmg").glob("*.dmg")) + list((release_dir / "bundle" / "macos").glob("*.app"))
|
||||
elif sys.platform == "win32":
|
||||
bundles = list(release_dir.glob("*.msi")) + list((release_dir / "bundle" / "msi").glob("*.msi"))
|
||||
else:
|
||||
bundles = list(release_dir.glob("GEO工具")) + list(release_dir.glob("*.AppImage"))
|
||||
for b in bundles:
|
||||
size_mb = b.stat().st_size / (1024 * 1024) if b.is_file() else sum(f.stat().st_size for f in b.rglob("*")) / (1024 * 1024)
|
||||
print(f" 输出: {b} ({size_mb:.1f} MB)")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
def build():
|
||||
"""执行 PyInstaller 打包"""
|
||||
# 确保 run_bundle.py 存在
|
||||
entry_point = PROJECT_ROOT / "run_bundle.py"
|
||||
if not entry_point.exists():
|
||||
print(f"[错误] 未找到入口文件: {entry_point}")
|
||||
sys.exit(1)
|
||||
|
||||
# 检查关键资源是否存在(给出警告而非中断)
|
||||
resources = [
|
||||
("geo_tool.py", "file"),
|
||||
("config.json", "file"),
|
||||
("geo_data.db", "file"),
|
||||
(".streamlit", "dir"),
|
||||
("modules", "dir"),
|
||||
]
|
||||
for path, _kind in resources:
|
||||
p = PROJECT_ROOT / path
|
||||
if not p.exists():
|
||||
print(f"[警告] 资源不存在: {p}")
|
||||
|
||||
# 构建命令
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"PyInstaller",
|
||||
str(SPEC_FILE),
|
||||
"--distpath",
|
||||
str(DIST_DIR),
|
||||
"--workpath",
|
||||
str(BUILD_DIR),
|
||||
"--clean",
|
||||
"--noconfirm",
|
||||
]
|
||||
|
||||
print("=" * 60)
|
||||
print(" GEO Tool — 打包构建")
|
||||
print("=" * 60)
|
||||
print(f" 平台: {sys.platform}")
|
||||
print(f" Python: {sys.version}")
|
||||
print(f" 入口: {entry_point}")
|
||||
print(f" Spec: {SPEC_FILE}")
|
||||
print(f" 输出目录: {DIST_DIR}")
|
||||
print("=" * 60)
|
||||
|
||||
# 执行打包
|
||||
result = subprocess.run(cmd, cwd=str(PROJECT_ROOT))
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"\n[错误] 打包失败 (返回码: {result.returncode})")
|
||||
print(f" 请检查 {BUILD_DIR}/ 下的日志文件获取详细错误信息。")
|
||||
sys.exit(1)
|
||||
|
||||
# 输出结果
|
||||
print("\n" + "=" * 60)
|
||||
print(" 打包完成!")
|
||||
print("=" * 60)
|
||||
|
||||
# 查找产物
|
||||
if sys.platform == "win32":
|
||||
output = DIST_DIR / "geo_tool_app.exe"
|
||||
elif sys.platform == "darwin":
|
||||
output = DIST_DIR / "geo_tool_app"
|
||||
else:
|
||||
output = DIST_DIR / "geo_tool_app"
|
||||
|
||||
if output.exists():
|
||||
size_mb = output.stat().st_size / (1024 * 1024)
|
||||
print(f" 输出文件: {output}")
|
||||
print(f" 文件大小: {size_mb:.1f} MB")
|
||||
else:
|
||||
# macOS 下可能生成 .app
|
||||
app_bundle = DIST_DIR / "geo_tool_app.app"
|
||||
if app_bundle.exists():
|
||||
size_mb = sum(f.stat().st_size for f in app_bundle.rglob("*")) / (1024 * 1024)
|
||||
print(f" 输出文件: {app_bundle}")
|
||||
print(f" 文件大小: {size_mb:.1f} MB")
|
||||
|
||||
print(f" 运行方式: 双击 {output.name} 或终端执行 {output}")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="GEO Tool 跨平台打包脚本",
|
||||
)
|
||||
parser.add_argument(
|
||||
"mode",
|
||||
nargs="?",
|
||||
default="python",
|
||||
choices=["python", "tauri"],
|
||||
help="打包模式: python (默认) 或 tauri (Python + 桌面壳)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--clean",
|
||||
action="store_true",
|
||||
help="构建前清理旧的 dist 和 build 目录",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-deps",
|
||||
action="store_true",
|
||||
help="跳过依赖检查(仅安装 PyInstaller)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# 切换到项目根目录
|
||||
os.chdir(str(PROJECT_ROOT))
|
||||
|
||||
# 清理
|
||||
if args.clean:
|
||||
print("清理旧的构建产物 ...")
|
||||
clean_build()
|
||||
|
||||
# 检查环境
|
||||
if not args.skip_deps:
|
||||
check_prerequisites()
|
||||
|
||||
# 打包
|
||||
if args.mode == "tauri":
|
||||
build_tauri()
|
||||
else:
|
||||
build()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,16 @@
|
||||
## 写一个脚本,用于进入tauri项目目录,安装依赖,并运行tauri开发环境
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
|
||||
def main():
|
||||
# 进入tauri项目目录
|
||||
os.chdir("./src-tauri")
|
||||
|
||||
# 安装依赖
|
||||
subprocess.check_call(["cargo", "run"])
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -22,10 +22,10 @@ from modules.ui.tab_knowledge import render_tab_knowledge
|
||||
from modules.ui.state import ss_init, init_session_state
|
||||
from modules.ui.theme import inject_global_theme
|
||||
|
||||
APP_TITLE = "GEO 智能内容优化平台"
|
||||
APP_TITLE = "丑橘GEO内容优化平台"
|
||||
|
||||
# ------------------- 页面配置 & 极简美学 CSS(产品级精修,仍然克制) -------------------
|
||||
st.set_page_config(page_title="GEO 智能内容优化平台", layout="wide", initial_sidebar_state="expanded")
|
||||
st.set_page_config(page_title="丑橘GEO内容优化平台", layout="wide", initial_sidebar_state="collapsed")
|
||||
|
||||
inject_global_theme()
|
||||
init_session_state()
|
||||
@@ -62,62 +62,8 @@ def record_api_cost(operation_type: str, provider: str, model: str, input_text:
|
||||
import logging
|
||||
logging.warning(f"记录 API 成本失败: {e}")
|
||||
|
||||
with st.expander("📖 关于 GEO(Generative Engine Optimization)", expanded=False):
|
||||
st.markdown("""
|
||||
### 🎯 什么是 GEO?
|
||||
|
||||
**GEO(Generative Engine Optimization,生成式引擎优化)** 是针对 AI 搜索引擎的内容优化策略。
|
||||
|
||||
传统 SEO 优化的是 Google、百度等传统搜索引擎的排名;而 GEO 优化的是 ChatGPT、Perplexity、Google SGE 等 AI 搜索引擎在回答用户问题时,**是否会引用您的品牌和内容**。
|
||||
|
||||
### 💡 为什么需要 GEO?
|
||||
|
||||
当用户向 AI 提问时(例如"最好的 XX 软件是什么?"),AI 会从互联网内容中检索信息并生成回答。如果您的品牌没有出现在 AI 可检索的高质量内容中,就会在 AI 时代失去曝光机会。
|
||||
|
||||
**GEO 的目标**:让您的品牌在 AI 回答中被优先、准确、可信地提及。
|
||||
|
||||
---
|
||||
|
||||
### 🔄 GEO 优化工作流
|
||||
|
||||
本工具提供完整的 GEO 优化闭环:
|
||||
|
||||
| 阶段 | 功能 | 说明 |
|
||||
|------|------|------|
|
||||
| 1️⃣ 关键词策略 | 关键词蒸馏 | 生成针对 AI 搜索的口语化、长尾关键词 |
|
||||
| 2️⃣ 内容创作 | 自动创作 | 基于知识库生成结构化、专业的内容 |
|
||||
| 3️⃣ 内容优化 | 文章优化 | E-E-A-T 强化、事实密度增强、Schema 生成 |
|
||||
| 4️⃣ 效果验证 | 多模型验证 | 用多个 AI 模型验证品牌是否被提及 |
|
||||
| 5️⃣ 数据分析 | AI 数据报表 | 提及率趋势、ROI 分析、竞品对比 |
|
||||
| 6️⃣ 内容分发 | 平台同步 | 多平台发布,扩大 AI 可检索内容 |
|
||||
|
||||
---
|
||||
|
||||
### 📊 GEO 核心指标
|
||||
|
||||
| 指标 | 说明 |
|
||||
|------|------|
|
||||
| **品牌提及率** | AI 回答中提及品牌的频率 |
|
||||
| **E-E-A-T 评分** | 专业性、经验性、权威性、可信度 |
|
||||
| **事实密度** | 内容中可验证信息的密度 |
|
||||
| **引用位置** | 品牌在 AI 回答中的位置(前 1/3 优先) |
|
||||
|
||||
---
|
||||
|
||||
### 🌐 支持平台
|
||||
|
||||
**内容发布平台(20+)**:知乎、小红书、CSDN、B站、GitHub、微信公众号等
|
||||
|
||||
**AI 验证平台(7)**:DeepSeek、OpenAI、通义千问、Groq、Moonshot、豆包、文心一言
|
||||
|
||||
---
|
||||
|
||||
### 📚 更多资源
|
||||
|
||||
- [GEO 学术论文](https://arxiv.org/abs/2311.09735) - GEO 原始研究
|
||||
- [项目文档](DOCS.md) - 完整功能文档
|
||||
- [快速开始](docs/guides/QUICK_START_GUIDE.md) - 新手入门指南
|
||||
""")
|
||||
# =================== 函数定义:配置管理(在 expander 之前定义,因为 expander 内使用了它们) ===================
|
||||
|
||||
def load_default_cfg():
|
||||
"""
|
||||
@@ -153,7 +99,6 @@ def load_default_cfg():
|
||||
# 从 st.secrets 读取敏感信息(优先级更高)
|
||||
try:
|
||||
if hasattr(st, 'secrets') and st.secrets:
|
||||
# 读取 API Keys
|
||||
if "api_keys" in st.secrets:
|
||||
api_keys = st.secrets["api_keys"]
|
||||
if "deepseek" in api_keys and api_keys["deepseek"]:
|
||||
@@ -161,15 +106,13 @@ def load_default_cfg():
|
||||
base_cfg["verify_keys"]["DeepSeek"] = api_keys["deepseek"]
|
||||
if "tongyi_wanxiang" in api_keys and api_keys["tongyi_wanxiang"]:
|
||||
base_cfg["tongyi_wanxiang_api_key"] = api_keys["tongyi_wanxiang"]
|
||||
|
||||
# 读取应用配置(如果存在)
|
||||
|
||||
if "app_config" in st.secrets:
|
||||
app_config = st.secrets["app_config"]
|
||||
for key in ["brand", "advantages", "competitors", "temperature"]:
|
||||
if key in app_config and app_config[key]:
|
||||
base_cfg[key] = app_config[key]
|
||||
except FileNotFoundError:
|
||||
# secrets.toml 不存在时静默忽略,用户可通过侧边栏配置
|
||||
pass
|
||||
except Exception as e:
|
||||
import logging
|
||||
@@ -180,9 +123,14 @@ def load_default_cfg():
|
||||
|
||||
def save_cfg_to_file(cfg: dict) -> None:
|
||||
"""
|
||||
将当前生效的非敏感配置写入本地 config.json。
|
||||
敏感信息(API Keys)不会保存到此文件,仅保存到 .streamlit/secrets.toml。
|
||||
将配置持久化到本地文件:
|
||||
- 非敏感配置 → config.json
|
||||
- API Keys + 品牌信息 → .streamlit/secrets.toml
|
||||
"""
|
||||
import tomllib
|
||||
import tomli_w # type: ignore
|
||||
|
||||
# ── 1. 非敏感配置 → config.json ──
|
||||
config_path = Path(__file__).with_name("config.json")
|
||||
try:
|
||||
data = {}
|
||||
@@ -194,50 +142,132 @@ def save_cfg_to_file(cfg: dict) -> None:
|
||||
data.update(loaded)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"读取配置文件失败: {e}")
|
||||
logging.warning(f"读取 config.json 失败: {e}")
|
||||
data = {}
|
||||
|
||||
# 只保存非敏感配置
|
||||
sensitive_keys = {"gen_api_key", "verify_keys", "tongyi_wanxiang_api_key"}
|
||||
|
||||
for key in ["gen_provider", "verify_providers", "brand", "advantages", "competitors", "temperature"]:
|
||||
if key in cfg:
|
||||
data[key] = cfg[key]
|
||||
|
||||
|
||||
with config_path.open("w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# 提示用户如何保存 API Keys
|
||||
if any(key in cfg for key in sensitive_keys):
|
||||
try:
|
||||
st.info("💡 API Keys 需要在 `.streamlit/secrets.toml` 文件中手动配置。")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.error(f"保存配置文件失败: {e}")
|
||||
logging.error(f"保存 config.json 失败: {e}")
|
||||
|
||||
# ── 2. API Keys + 品牌信息 → .streamlit/secrets.toml ──
|
||||
secrets_path = Path(__file__).parent / ".streamlit" / "secrets.toml"
|
||||
try:
|
||||
# 读取现有的 secrets.toml(如果存在)
|
||||
existing = {}
|
||||
if secrets_path.exists():
|
||||
try:
|
||||
with secrets_path.open("rb") as f:
|
||||
existing = tomllib.load(f)
|
||||
except Exception:
|
||||
existing = {}
|
||||
|
||||
# 构建新的 api_keys 段
|
||||
new_api_keys = existing.get("api_keys", {})
|
||||
|
||||
# gen_api_key → deepseek
|
||||
if "gen_api_key" in cfg and cfg["gen_api_key"]:
|
||||
new_api_keys["deepseek"] = cfg["gen_api_key"]
|
||||
|
||||
# verify_keys → 各 provider 的 key
|
||||
verify_keys = cfg.get("verify_keys", {})
|
||||
provider_map = {
|
||||
"DeepSeek": "deepseek",
|
||||
"OpenAI (GPT)": "openai",
|
||||
"Groq": "groq",
|
||||
"Moonshot (Kimi)": "moonshot",
|
||||
"Tongyi (通义千问)": "tongyi",
|
||||
"豆包(字节跳动)": "doubao",
|
||||
"文心一言(百度)": "wenxin",
|
||||
}
|
||||
for display_name, key_name in provider_map.items():
|
||||
if display_name in verify_keys and verify_keys[display_name]:
|
||||
new_api_keys[key_name] = verify_keys[display_name]
|
||||
|
||||
# tongyi_wanxiang_api_key
|
||||
if "tongyi_wanxiang_api_key" in cfg and cfg["tongyi_wanxiang_api_key"]:
|
||||
new_api_keys["tongyi_wanxiang"] = cfg["tongyi_wanxiang_api_key"]
|
||||
|
||||
# 构建新的 app_config 段
|
||||
new_app_config = existing.get("app_config", {})
|
||||
for key in ["brand", "advantages", "competitors"]:
|
||||
if key in cfg and cfg[key]:
|
||||
new_app_config[key] = cfg[key]
|
||||
if "temperature" in cfg:
|
||||
new_app_config["temperature"] = cfg["temperature"]
|
||||
|
||||
# 写出 TOML
|
||||
secrets_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with secrets_path.open("wb") as f:
|
||||
content = {"api_keys": new_api_keys, "app_config": new_app_config}
|
||||
tomli_w.dump(content, f)
|
||||
|
||||
except ImportError:
|
||||
# tomli_w 未安装 — 回退到手动写 TOML 字符串
|
||||
_save_secrets_fallback(secrets_path, cfg)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.error(f"保存 secrets.toml 失败: {e}")
|
||||
try:
|
||||
st.warning("⚠️ 无法将配置写入本地 config.json,但当前会话已生效。请检查文件权限。")
|
||||
st.warning("⚠️ 无法将 API Key 写入 .streamlit/secrets.toml,但当前会话已生效。")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
ss_init("cfg", load_default_cfg())
|
||||
def _save_secrets_fallback(secrets_path: Path, cfg: dict) -> None:
|
||||
"""tomli_w 不可用时的纯文本备选方案"""
|
||||
try:
|
||||
secrets_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 模块1:关键词(补充 init_session_state 中未包含的)
|
||||
ss_init("keyword_tool", KeywordTool()) # 托词工具实例
|
||||
lines = ["# Streamlit Secrets(自动保存)\n"]
|
||||
|
||||
lines.append("\n[api_keys]\n")
|
||||
if cfg.get("gen_api_key"):
|
||||
lines.append(f'deepseek = "{cfg["gen_api_key"]}"\n')
|
||||
|
||||
verify_keys = cfg.get("verify_keys", {})
|
||||
provider_map = {
|
||||
"DeepSeek": "deepseek",
|
||||
"OpenAI (GPT)": "openai",
|
||||
"Groq": "groq",
|
||||
"Moonshot (Kimi)": "moonshot",
|
||||
"Tongyi (通义千问)": "tongyi",
|
||||
"豆包(字节跳动)": "doubao",
|
||||
"文心一言(百度)": "wenxin",
|
||||
}
|
||||
for display_name, key_name in provider_map.items():
|
||||
if display_name in verify_keys and verify_keys[display_name]:
|
||||
lines.append(f'{key_name} = "{verify_keys[display_name]}"\n')
|
||||
|
||||
if cfg.get("tongyi_wanxiang_api_key"):
|
||||
lines.append(f'tongyi_wanxiang = "{cfg["tongyi_wanxiang_api_key"]}"\n')
|
||||
|
||||
lines.append("\n[app_config]\n")
|
||||
for key in ["brand", "advantages", "competitors"]:
|
||||
if cfg.get(key):
|
||||
lines.append(f'{key} = "{cfg[key]}"\n')
|
||||
if "temperature" in cfg:
|
||||
lines.append(f"temperature = {cfg['temperature']}\n")
|
||||
|
||||
with secrets_path.open("w", encoding="utf-8") as f:
|
||||
f.writelines(lines)
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.error(f"保存 secrets.toml(备选方案)失败: {e}")
|
||||
|
||||
# 模块2:内容(补充 init_session_state 中未包含的)
|
||||
ss_init("multimodal_descriptions", {}) # 多模态描述(配图描述、视频脚本等)
|
||||
ss_init("image_descriptions", []) # 图片描述列表
|
||||
ss_init("detail_tab_active", "🎨 增强工具") # 保存当前激活的详情Tab
|
||||
|
||||
# ------------------- 工具函数 -------------------
|
||||
def validate_cfg(cfg: dict):
|
||||
"""验证配置完整性,返回 (是否有效, 错误列表)。"""
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
|
||||
if not cfg.get("gen_api_key", "").strip():
|
||||
errors.append("生成&优化 LLM 的 API Key 未填写")
|
||||
|
||||
@@ -249,7 +279,7 @@ def validate_cfg(cfg: dict):
|
||||
for vp in verify_providers:
|
||||
if not verify_keys.get(vp, "").strip():
|
||||
errors.append(f"验证模型 {vp} 的 API Key 未填写")
|
||||
|
||||
|
||||
if not cfg.get("brand", "").strip():
|
||||
warnings.append("品牌名称未填写(部分功能需要)")
|
||||
if not cfg.get("advantages", "").strip():
|
||||
@@ -258,28 +288,12 @@ def validate_cfg(cfg: dict):
|
||||
return (len(errors) == 0), errors + warnings
|
||||
|
||||
|
||||
def model_defaults(provider: str) -> str:
|
||||
from modules.llm_factory import get_default_model
|
||||
return get_default_model(provider)
|
||||
# 初始化默认配置(要在 expander 之前,因为 expander 内访问了 cfg)
|
||||
ss_init("cfg", load_default_cfg())
|
||||
|
||||
|
||||
# ------------------- 缓存 LLM 客户端(显著降低“频繁 Loading”) -------------------
|
||||
@st.cache_resource(show_spinner=False)
|
||||
def build_llm(provider: str, api_key: str, model: str, temperature: float):
|
||||
"""
|
||||
- 使用 cache_resource 缓存客户端,避免每次 rerun 重建
|
||||
- 统一使用 llm_factory 模块构建 LLM
|
||||
"""
|
||||
from modules.llm_factory import build_llm as _build_llm
|
||||
return _build_llm(provider, api_key, model, temperature)
|
||||
|
||||
|
||||
# ------------------- 侧边栏:全局配置(分组折叠) -------------------
|
||||
with st.sidebar:
|
||||
st.header("⚙️ 全局配置")
|
||||
with st.expander("配置", expanded=False):
|
||||
|
||||
# LLM 配置组
|
||||
with st.expander("🤖 LLM 配置", expanded=True):
|
||||
with st.expander("LLM 配置", expanded=True):
|
||||
PROVIDER_LIST = ["DeepSeek", "OpenAI (GPT)", "Tongyi (通义千问)", "Groq", "Moonshot (Kimi)", "豆包(字节跳动)", "文心一言(百度)"]
|
||||
|
||||
gen_provider = st.selectbox(
|
||||
@@ -423,6 +437,28 @@ with st.sidebar:
|
||||
|
||||
st.caption("闭环:关键词 → 创作 → 优化 → 验证")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def model_defaults(provider: str) -> str:
|
||||
from modules.llm_factory import get_default_model
|
||||
return get_default_model(provider)
|
||||
|
||||
|
||||
# ------------------- 缓存 LLM 客户端(显著降低“频繁 Loading”) -------------------
|
||||
@st.cache_resource(show_spinner=False)
|
||||
def build_llm(provider: str, api_key: str, model: str, temperature: float):
|
||||
"""
|
||||
- 使用 cache_resource 缓存客户端,避免每次 rerun 重建
|
||||
- 统一使用 llm_factory 模块构建 LLM
|
||||
"""
|
||||
from modules.llm_factory import build_llm as _build_llm
|
||||
return _build_llm(provider, api_key, model, temperature)
|
||||
|
||||
|
||||
# ------------------- 侧边栏:全局配置(分组折叠) -------------------
|
||||
|
||||
cfg = st.session_state.cfg
|
||||
brand = cfg["brand"]
|
||||
advantages = cfg["advantages"]
|
||||
@@ -620,4 +656,4 @@ with tab10:
|
||||
with tab11:
|
||||
render_tab_knowledge(kb)
|
||||
|
||||
st.caption("最完整版:GitHub模板 + 真实多模型验证 + 现有文章优化 + RAG知识库 • GEO全闭环,专注AI品牌影响力")
|
||||
st.caption("一站式GEO优化平台| 多模型验证 + 文章优化 + RAG知识库 • GEO全闭环,专注AI品牌影响力")
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
import streamlit as st
|
||||
from pathlib import Path
|
||||
import json
|
||||
from typing import Optional
|
||||
from modules.data_storage import DataStorage
|
||||
from modules.keyword_tool import KeywordTool
|
||||
from modules.roi_analyzer import ROIAnalyzer
|
||||
from modules.knowledge_base import KnowledgeBase
|
||||
from modules.ui import (
|
||||
tab_keywords,
|
||||
tab_autowrite,
|
||||
tab_optimize,
|
||||
tab_validation,
|
||||
tab_history,
|
||||
tab_reports,
|
||||
tab_workflow,
|
||||
tab_resources,
|
||||
tab_platform_sync,
|
||||
tab_config_optimizer,
|
||||
)
|
||||
from modules.ui.tab_knowledge import render_tab_knowledge
|
||||
from modules.ui.state import ss_init, init_session_state
|
||||
from modules.ui.theme import inject_global_theme
|
||||
|
||||
APP_TITLE = "GEO 智能内容优化平台"
|
||||
|
||||
# ------------------- 页面配置 & 极简美学 CSS(产品级精修,仍然克制) -------------------
|
||||
st.set_page_config(page_title="GEO 智能内容优化平台", layout="wide", initial_sidebar_state="expanded")
|
||||
|
||||
inject_global_theme()
|
||||
init_session_state()
|
||||
st.title(APP_TITLE)
|
||||
|
||||
st.caption("🚀 AI 驱动的品牌内容策略 · 让您的品牌在 AI 对话中脱颖而出")
|
||||
|
||||
# ------------------- 初始化数据存储(SQLite) -------------------
|
||||
storage = DataStorage(storage_type="sqlite", db_path="geo_data.db")
|
||||
|
||||
# ------------------- 初始化知识库(RAG) -------------------
|
||||
kb = KnowledgeBase(storage_path="knowledge_base")
|
||||
|
||||
# ------------------- 成本记录辅助函数 -------------------
|
||||
def estimate_tokens(text: str) -> int:
|
||||
"""估算文本的 token 数量:中文约 1.5 字符 = 1 token,英文约 4 字符 = 1 token"""
|
||||
if not text:
|
||||
return 0
|
||||
chinese_chars = sum(1 for char in text if '\u4e00' <= char <= '\u9fff')
|
||||
other_chars = len(text) - chinese_chars
|
||||
estimated_tokens = int(chinese_chars / 1.5 + other_chars / 4)
|
||||
return max(estimated_tokens, len(text) // 4)
|
||||
|
||||
def record_api_cost(operation_type: str, provider: str, model: str, input_text: str, output_text: str, keyword: Optional[str] = None, platform: Optional[str] = None, brand: Optional[str] = None):
|
||||
"""记录 API 调用成本"""
|
||||
try:
|
||||
roi_analyzer = ROIAnalyzer()
|
||||
input_tokens = estimate_tokens(input_text)
|
||||
output_tokens = estimate_tokens(output_text)
|
||||
total_tokens = input_tokens + output_tokens
|
||||
cost_usd, cost_cny = roi_analyzer.calculate_cost(provider, model, input_tokens, output_tokens)
|
||||
storage.save_api_call(operation_type=operation_type, provider=provider, model=model, input_tokens=input_tokens, output_tokens=output_tokens, total_tokens=total_tokens, cost_usd=cost_usd, cost_cny=cost_cny, keyword=keyword, platform=platform, brand=brand)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"记录 API 成本失败: {e}")
|
||||
|
||||
def load_default_cfg():
|
||||
"""
|
||||
从项目根目录的 config.json 读取默认配置,如果不存在则使用内置默认值。
|
||||
敏感信息(API Keys)优先从 .streamlit/secrets.toml 读取。
|
||||
"""
|
||||
base_cfg = {
|
||||
"gen_provider": "DeepSeek",
|
||||
"gen_api_key": "",
|
||||
"verify_providers": ["DeepSeek"],
|
||||
"verify_keys": {
|
||||
"DeepSeek": ""
|
||||
},
|
||||
"tongyi_wanxiang_api_key": "",
|
||||
"brand": "",
|
||||
"advantages": "",
|
||||
"competitors": "",
|
||||
"temperature": 0.7,
|
||||
}
|
||||
|
||||
# 从 config.json 读取非敏感配置
|
||||
config_path = Path(__file__).with_name("config.json")
|
||||
if config_path.exists():
|
||||
try:
|
||||
with config_path.open("r", encoding="utf-8") as f:
|
||||
file_cfg = json.load(f)
|
||||
if isinstance(file_cfg, dict):
|
||||
base_cfg.update(file_cfg)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"配置文件加载失败: {e}")
|
||||
|
||||
# 从 st.secrets 读取敏感信息(优先级更高)
|
||||
try:
|
||||
if hasattr(st, 'secrets') and st.secrets:
|
||||
# 读取 API Keys
|
||||
if "api_keys" in st.secrets:
|
||||
api_keys = st.secrets["api_keys"]
|
||||
if "deepseek" in api_keys and api_keys["deepseek"]:
|
||||
base_cfg["gen_api_key"] = api_keys["deepseek"]
|
||||
base_cfg["verify_keys"]["DeepSeek"] = api_keys["deepseek"]
|
||||
if "tongyi_wanxiang" in api_keys and api_keys["tongyi_wanxiang"]:
|
||||
base_cfg["tongyi_wanxiang_api_key"] = api_keys["tongyi_wanxiang"]
|
||||
|
||||
# 读取应用配置(如果存在)
|
||||
if "app_config" in st.secrets:
|
||||
app_config = st.secrets["app_config"]
|
||||
for key in ["brand", "advantages", "competitors", "temperature"]:
|
||||
if key in app_config and app_config[key]:
|
||||
base_cfg[key] = app_config[key]
|
||||
except FileNotFoundError:
|
||||
# secrets.toml 不存在时静默忽略,用户可通过侧边栏配置
|
||||
pass
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"读取 secrets.toml 失败: {e}")
|
||||
|
||||
return base_cfg
|
||||
|
||||
|
||||
def save_cfg_to_file(cfg: dict) -> None:
|
||||
"""
|
||||
将当前生效的非敏感配置写入本地 config.json。
|
||||
敏感信息(API Keys)不会保存到此文件,仅保存到 .streamlit/secrets.toml。
|
||||
"""
|
||||
config_path = Path(__file__).with_name("config.json")
|
||||
try:
|
||||
data = {}
|
||||
if config_path.exists():
|
||||
try:
|
||||
with config_path.open("r", encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
if isinstance(loaded, dict):
|
||||
data.update(loaded)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"读取配置文件失败: {e}")
|
||||
data = {}
|
||||
|
||||
# 只保存非敏感配置
|
||||
sensitive_keys = {"gen_api_key", "verify_keys", "tongyi_wanxiang_api_key"}
|
||||
for key in ["gen_provider", "verify_providers", "brand", "advantages", "competitors", "temperature"]:
|
||||
if key in cfg:
|
||||
data[key] = cfg[key]
|
||||
|
||||
with config_path.open("w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# 提示用户如何保存 API Keys
|
||||
if any(key in cfg for key in sensitive_keys):
|
||||
try:
|
||||
st.info("💡 API Keys 需要在 `.streamlit/secrets.toml` 文件中手动配置。")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.error(f"保存配置文件失败: {e}")
|
||||
try:
|
||||
st.warning("⚠️ 无法将配置写入本地 config.json,但当前会话已生效。请检查文件权限。")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
ss_init("cfg", load_default_cfg())
|
||||
|
||||
# 模块1:关键词(补充 init_session_state 中未包含的)
|
||||
ss_init("keyword_tool", KeywordTool()) # 托词工具实例
|
||||
|
||||
# 模块2:内容(补充 init_session_state 中未包含的)
|
||||
ss_init("multimodal_descriptions", {}) # 多模态描述(配图描述、视频脚本等)
|
||||
ss_init("image_descriptions", []) # 图片描述列表
|
||||
ss_init("detail_tab_active", "🎨 增强工具") # 保存当前激活的详情Tab
|
||||
|
||||
# ------------------- 工具函数 -------------------
|
||||
def validate_cfg(cfg: dict):
|
||||
"""验证配置完整性,返回 (是否有效, 错误列表)。"""
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
if not cfg.get("gen_api_key", "").strip():
|
||||
errors.append("生成&优化 LLM 的 API Key 未填写")
|
||||
|
||||
verify_providers = cfg.get("verify_providers", [])
|
||||
verify_keys = cfg.get("verify_keys", {})
|
||||
if not verify_providers:
|
||||
errors.append("至少选择一个验证模型")
|
||||
|
||||
for vp in verify_providers:
|
||||
if not verify_keys.get(vp, "").strip():
|
||||
errors.append(f"验证模型 {vp} 的 API Key 未填写")
|
||||
|
||||
if not cfg.get("brand", "").strip():
|
||||
warnings.append("品牌名称未填写(部分功能需要)")
|
||||
if not cfg.get("advantages", "").strip():
|
||||
warnings.append("核心优势未填写(部分功能需要)")
|
||||
|
||||
return (len(errors) == 0), errors + warnings
|
||||
|
||||
|
||||
def model_defaults(provider: str) -> str:
|
||||
from modules.llm_factory import get_default_model
|
||||
return get_default_model(provider)
|
||||
|
||||
|
||||
# ------------------- 缓存 LLM 客户端(显著降低“频繁 Loading”) -------------------
|
||||
@st.cache_resource(show_spinner=False)
|
||||
def build_llm(provider: str, api_key: str, model: str, temperature: float):
|
||||
|
||||
with st.expander("📖 关于 GEO(Generative Engine Optimization)", expanded=False):
|
||||
st.markdown("""
|
||||
|
||||
**GEO(Generative Engine Optimization,生成式引擎优化)** 是针对 AI 搜索引擎的内容优化策略。
|
||||
|
||||
传统 SEO 优化的是 Google、百度等传统搜索引擎的排名;而 GEO 优化的是 ChatGPT、Perplexity、Google SGE 等 AI 搜索引擎在回答用户问题时,**是否会引用您的品牌和内容**。
|
||||
|
||||
### 💡 为什么需要 GEO?
|
||||
|
||||
当用户向 AI 提问时(例如"最好的 XX 软件是什么?"),AI 会从互联网内容中检索信息并生成回答。如果您的品牌没有出现在 AI 可检索的高质量内容中,就会在 AI 时代失去曝光机会。
|
||||
|
||||
**GEO 的目标**:让您的品牌在 AI 回答中被优先、准确、可信地提及。
|
||||
|
||||
---
|
||||
|
||||
### 🔄 GEO 优化工作流
|
||||
|
||||
本工具提供完整的 GEO 优化闭环:
|
||||
|
||||
| 阶段 | 功能 | 说明 |
|
||||
|------|------|------|
|
||||
| 1️⃣ 关键词策略 | 关键词蒸馏 | 生成针对 AI 搜索的口语化、长尾关键词 |
|
||||
| 2️⃣ 内容创作 | 自动创作 | 基于知识库生成结构化、专业的内容 |
|
||||
| 3️⃣ 内容优化 | 文章优化 | E-E-A-T 强化、事实密度增强、Schema 生成 |
|
||||
| 4️⃣ 效果验证 | 多模型验证 | 用多个 AI 模型验证品牌是否被提及 |
|
||||
| 5️⃣ 数据分析 | AI 数据报表 | 提及率趋势、ROI 分析、竞品对比 |
|
||||
| 6️⃣ 内容分发 | 平台同步 | 多平台发布,扩大 AI 可检索内容 |
|
||||
|
||||
---
|
||||
|
||||
### 📊 GEO 核心指标
|
||||
|
||||
| 指标 | 说明 |
|
||||
|------|------|
|
||||
| **品牌提及率** | AI 回答中提及品牌的频率 |
|
||||
| **E-E-A-T 评分** | 专业性、经验性、权威性、可信度 |
|
||||
| **事实密度** | 内容中可验证信息的密度 |
|
||||
| **引用位置** | 品牌在 AI 回答中的位置(前 1/3 优先) |
|
||||
|
||||
---
|
||||
|
||||
### 🌐 支持平台
|
||||
|
||||
**内容发布平台(20+)**:知乎、小红书、CSDN、B站、GitHub、微信公众号等
|
||||
|
||||
**AI 验证平台(7)**:DeepSeek、OpenAI、通义千问、Groq、Moonshot、豆包、文心一言
|
||||
|
||||
---
|
||||
|
||||
### 📚 更多资源
|
||||
|
||||
- [GEO 学术论文](https://arxiv.org/abs/2311.09735) - GEO 原始研究
|
||||
- [项目文档](DOCS.md) - 完整功能文档
|
||||
- [快速开始](docs/guides/QUICK_START_GUIDE.md) - 新手入门指南
|
||||
""")
|
||||
""")
|
||||
# LLM 配置组
|
||||
with st.expander("🤖 LLM 配置", expanded=True):
|
||||
PROVIDER_LIST = ["DeepSeek", "OpenAI (GPT)", "Tongyi (通义千问)", "Groq", "Moonshot (Kimi)", "豆包(字节跳动)", "文心一言(百度)"]
|
||||
|
||||
gen_provider = st.selectbox(
|
||||
"生成&优化 LLM",
|
||||
PROVIDER_LIST,
|
||||
index=PROVIDER_LIST.index(st.session_state.cfg["gen_provider"]) if st.session_state.cfg["gen_provider"] in PROVIDER_LIST else 0,
|
||||
key="sb_gen_provider",
|
||||
)
|
||||
|
||||
# API Key 输入提示
|
||||
api_key_help = ""
|
||||
if gen_provider == "豆包(字节跳动)":
|
||||
api_key_help = "格式:access_key:secret_key:endpoint_id(用冒号分隔)"
|
||||
elif gen_provider == "文心一言(百度)":
|
||||
api_key_help = "格式:app_key:app_secret(用冒号分隔)"
|
||||
|
||||
gen_api_key = st.text_input(
|
||||
f"{gen_provider} API Key(生成&优化用)",
|
||||
type="password",
|
||||
value=st.session_state.cfg.get("gen_api_key", ""),
|
||||
key="sb_gen_api_key",
|
||||
help=api_key_help if api_key_help else None,
|
||||
)
|
||||
|
||||
# 验证配置组
|
||||
with st.expander("🔍 验证配置", expanded=False):
|
||||
verify_providers = st.multiselect(
|
||||
"选择验证模型",
|
||||
PROVIDER_LIST,
|
||||
default=st.session_state.cfg.get("verify_providers", []),
|
||||
key="sb_verify_providers",
|
||||
)
|
||||
|
||||
verify_keys = {}
|
||||
old_keys = st.session_state.cfg.get("verify_keys", {})
|
||||
for vp in verify_providers:
|
||||
vp_help = ""
|
||||
if vp == "豆包(字节跳动)":
|
||||
vp_help = "格式:access_key:secret_key:endpoint_id(用冒号分隔)"
|
||||
elif vp == "文心一言(百度)":
|
||||
vp_help = "格式:app_key:app_secret(用冒号分隔)"
|
||||
|
||||
verify_keys[vp] = st.text_input(
|
||||
f"{vp} API Key(验证用)",
|
||||
type="password",
|
||||
value=old_keys.get(vp, ""),
|
||||
key=f"sb_verify_key_{vp}",
|
||||
help=vp_help if vp_help else None,
|
||||
)
|
||||
|
||||
# 品牌信息组
|
||||
with st.expander("🏢 品牌信息", expanded=True):
|
||||
brand = st.text_input("主品牌名称", value=st.session_state.cfg.get("brand", ""), key="sb_brand")
|
||||
|
||||
advantages = st.text_area(
|
||||
"核心优势/卖点(AI专属)",
|
||||
value=st.session_state.cfg.get("advantages", ""),
|
||||
height=120,
|
||||
key="sb_advantages",
|
||||
)
|
||||
|
||||
competitors = st.text_area(
|
||||
"竞品品牌(每行一个)",
|
||||
value=st.session_state.cfg.get("competitors", ""),
|
||||
height=100,
|
||||
key="sb_competitors",
|
||||
)
|
||||
|
||||
# 高级设置组
|
||||
with st.expander("⚙️ 高级设置", expanded=False):
|
||||
temperature = st.slider(
|
||||
"生成温度(更稳→更低)",
|
||||
0.0,
|
||||
1.0,
|
||||
float(st.session_state.cfg.get("temperature", 0.7)),
|
||||
0.05,
|
||||
key="sb_temperature",
|
||||
)
|
||||
|
||||
tongyi_wanxiang_api_key = st.text_input(
|
||||
"通义万相 API Key(图片生成)",
|
||||
type="password",
|
||||
value=st.session_state.cfg.get("tongyi_wanxiang_api_key", ""),
|
||||
key="sb_tongyi_wanxiang_api_key",
|
||||
help="阿里云 DashScope API Key,用于生成文章配图。",
|
||||
)
|
||||
|
||||
# 应用配置按钮
|
||||
apply_cfg = st.button("应用配置", use_container_width=True, type="primary")
|
||||
|
||||
if apply_cfg or not st.session_state.cfg_applied:
|
||||
# 优先从主 key 读取值(如果使用了临时 key 更新,值已同步到主 key)
|
||||
brand_value = st.session_state.get("sb_brand", brand)
|
||||
advantages_value = st.session_state.get("sb_advantages", advantages)
|
||||
|
||||
st.session_state.cfg = {
|
||||
"gen_provider": gen_provider,
|
||||
"gen_api_key": gen_api_key,
|
||||
"verify_providers": verify_providers,
|
||||
"verify_keys": verify_keys,
|
||||
"tongyi_wanxiang_api_key": tongyi_wanxiang_api_key,
|
||||
"brand": brand_value,
|
||||
"advantages": advantages_value,
|
||||
"competitors": competitors,
|
||||
"temperature": temperature,
|
||||
}
|
||||
|
||||
ok, errs = validate_cfg(st.session_state.cfg)
|
||||
st.session_state.cfg_valid = ok
|
||||
st.session_state.cfg_errors = errs
|
||||
|
||||
if ok:
|
||||
# 仅在配置合法时才写入本地配置文件,并标记为已应用
|
||||
save_cfg_to_file(st.session_state.cfg)
|
||||
st.session_state.cfg_applied = True
|
||||
else:
|
||||
st.session_state.cfg_applied = False
|
||||
|
||||
if not st.session_state.cfg_valid:
|
||||
with st.container(border=True):
|
||||
st.markdown("**⚠️ 完成配置后即可使用全部功能**")
|
||||
for err in st.session_state.cfg_errors:
|
||||
st.markdown(f"• {err}")
|
||||
else:
|
||||
with st.container(border=True):
|
||||
st.markdown("**✅ 配置已就绪**")
|
||||
st.caption("所有功能已解锁,可以开始使用")
|
||||
|
||||
st.markdown("---")
|
||||
if st.button("重置全部结果(不删除配置)", use_container_width=True, key="sb_reset_all"):
|
||||
st.session_state.keywords = []
|
||||
st.session_state.generated_contents = []
|
||||
st.session_state.zip_bytes = None
|
||||
st.session_state.zip_filename = ""
|
||||
st.session_state.optimized_article = ""
|
||||
st.session_state.opt_changes = ""
|
||||
st.session_state.verify_combined = None
|
||||
st.session_state.config_optimization_result = None
|
||||
st.session_state.config_hash = None
|
||||
st.toast("已重置全部结果。")
|
||||
|
||||
st.caption("闭环:关键词 → 创作 → 优化 → 验证")
|
||||
|
||||
cfg = st.session_state.cfg
|
||||
brand = cfg["brand"]
|
||||
advantages = cfg["advantages"]
|
||||
temperature = float(cfg.get("temperature", 0.7))
|
||||
|
||||
competitor_list = [c.strip() for c in cfg["competitors"].split("\n") if c.strip()]
|
||||
_seen = set()
|
||||
clean_competitors = []
|
||||
for c in competitor_list:
|
||||
cl = c.lower()
|
||||
if cl == brand.lower():
|
||||
continue
|
||||
if cl in _seen:
|
||||
continue
|
||||
_seen.add(cl)
|
||||
clean_competitors.append(c)
|
||||
competitor_list = clean_competitors
|
||||
|
||||
# ------------------- 初始化 LLM(仅在 cfg_valid 时;且 build_llm 已缓存) -------------------
|
||||
gen_llm = None
|
||||
verify_llms = {}
|
||||
|
||||
if st.session_state.cfg_valid:
|
||||
try:
|
||||
gen_llm = build_llm(cfg["gen_provider"], cfg["gen_api_key"], model_defaults(cfg["gen_provider"]), temperature)
|
||||
except Exception as e:
|
||||
st.error(f"生成LLM加载失败:{e}")
|
||||
|
||||
for vp in cfg["verify_providers"]:
|
||||
key = cfg["verify_keys"].get(vp, "").strip()
|
||||
if not key:
|
||||
continue
|
||||
try:
|
||||
verify_llms[vp] = build_llm(vp, key, model_defaults(vp), temperature)
|
||||
except Exception as e:
|
||||
st.error(f"{vp}验证LLM加载失败:{e}")
|
||||
|
||||
# ------------------- KPI 总览(极简但更像产品) -------------------
|
||||
k1, k2, k3, k4 = st.columns(4)
|
||||
k1.metric("关键词", len(st.session_state.keywords), border=True)
|
||||
k2.metric("内容包", len(st.session_state.generated_contents), border=True)
|
||||
k3.metric("文章优化", "已生成" if bool(st.session_state.optimized_article) else "未生成", border=True)
|
||||
k4.metric("验证结果", "已生成" if st.session_state.verify_combined is not None else "未生成", border=True)
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# ------------------- 主导航:Tabs(流程更清晰) -------------------
|
||||
tab1, tab2, tab3, tab4, tab5, tab6, tab7, tab8, tab9, tab10, tab11 = st.tabs([
|
||||
"🎯 关键词蒸馏",
|
||||
"✍️ 自动创作",
|
||||
"🔧 文章优化",
|
||||
"✅ 多模型验证",
|
||||
"📚 历史记录",
|
||||
"📊 AI 数据报表",
|
||||
"⚙️ 工作流自动化",
|
||||
"📦 GEO 资源库",
|
||||
"🔄 平台同步",
|
||||
"🛠️ 配置优化助手",
|
||||
"📚 品牌知识库"
|
||||
])
|
||||
|
||||
# =======================
|
||||
# Tab1:关键词蒸馏
|
||||
# =======================
|
||||
with tab1:
|
||||
tab_keywords.render_tab_keywords(
|
||||
storage,
|
||||
ss_init,
|
||||
gen_llm,
|
||||
brand,
|
||||
advantages
|
||||
)
|
||||
|
||||
|
||||
# =======================
|
||||
# Tab2:自动创作内容(含批量 ZIP / GitHub 模板)
|
||||
# =======================
|
||||
with tab2:
|
||||
tab_autowrite.render_tab_autowrite(
|
||||
storage,
|
||||
ss_init,
|
||||
gen_llm,
|
||||
brand,
|
||||
advantages,
|
||||
cfg,
|
||||
record_api_cost,
|
||||
model_defaults
|
||||
)
|
||||
|
||||
|
||||
# =======================
|
||||
# Tab3:文章优化
|
||||
# =======================
|
||||
with tab3:
|
||||
tab_optimize.render_tab_optimize(
|
||||
storage,
|
||||
ss_init,
|
||||
gen_llm,
|
||||
brand,
|
||||
advantages,
|
||||
cfg,
|
||||
record_api_cost,
|
||||
model_defaults,
|
||||
)
|
||||
|
||||
|
||||
# =======================
|
||||
# Tab4:多模型验证 & 竞品对比
|
||||
# =======================
|
||||
with tab4:
|
||||
tab_validation.render_tab_validation(
|
||||
storage,
|
||||
ss_init,
|
||||
brand,
|
||||
advantages,
|
||||
competitor_list,
|
||||
verify_llms,
|
||||
record_api_cost,
|
||||
model_defaults,
|
||||
)
|
||||
|
||||
|
||||
# =======================
|
||||
# Tab5:历史记录
|
||||
# =======================
|
||||
with tab5:
|
||||
tab_history.render_tab_history(storage, brand)
|
||||
|
||||
|
||||
# =======================
|
||||
# Tab6:AI 数据报表
|
||||
# =======================
|
||||
with tab6:
|
||||
tab_reports.render_tab_reports(
|
||||
storage,
|
||||
ss_init,
|
||||
gen_llm,
|
||||
brand,
|
||||
advantages,
|
||||
competitor_list,
|
||||
verify_llms,
|
||||
record_api_cost,
|
||||
model_defaults,
|
||||
)
|
||||
|
||||
|
||||
# =======================
|
||||
# Tab7:工作流自动化
|
||||
# =======================
|
||||
with tab7:
|
||||
tab_workflow.render_tab_workflow(
|
||||
storage,
|
||||
ss_init,
|
||||
gen_llm,
|
||||
brand,
|
||||
advantages,
|
||||
competitor_list,
|
||||
verify_llms,
|
||||
record_api_cost,
|
||||
model_defaults,
|
||||
)
|
||||
|
||||
# =======================
|
||||
# Tab8:GEO 资源库
|
||||
# =======================
|
||||
with tab8:
|
||||
tab_resources.render_tab_resources(storage, brand)
|
||||
|
||||
|
||||
# =======================
|
||||
# Tab9:平台同步
|
||||
# =======================
|
||||
with tab9:
|
||||
tab_platform_sync.render_tab_platform_sync(storage, brand)
|
||||
|
||||
|
||||
# =======================
|
||||
# Tab10:配置优化助手
|
||||
# =======================
|
||||
with tab10:
|
||||
tab_config_optimizer.render_tab_config_optimizer(
|
||||
storage,
|
||||
cfg,
|
||||
brand,
|
||||
advantages,
|
||||
competitor_list,
|
||||
build_llm,
|
||||
model_defaults,
|
||||
)
|
||||
|
||||
|
||||
# =======================
|
||||
# Tab11:品牌知识库(RAG)
|
||||
# =======================
|
||||
with tab11:
|
||||
render_tab_knowledge(kb)
|
||||
|
||||
st.caption("最完整版:GitHub模板 + 真实多模型验证 + 现有文章优化 + RAG知识库 • GEO全闭环,专注AI品牌影响力")
|
||||
@@ -0,0 +1,23 @@
|
||||
import sys
|
||||
|
||||
def main():
|
||||
|
||||
sys.argv = [
|
||||
"streamlit",
|
||||
"run",
|
||||
"geo_tool.py",
|
||||
"--server.port", "8501",
|
||||
"--server.address", "127.0.0.1",
|
||||
"--server.headless", "true",
|
||||
"--browser.gatherUsageStats", "false",
|
||||
"--global.developmentMode", "false",
|
||||
"--logger.level", "error",
|
||||
]
|
||||
|
||||
from streamlit.web.cli import main as streamlit_main
|
||||
streamlit_main()
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -521,6 +521,30 @@ div[data-testid="stVerticalBlock"] > div {
|
||||
[data-testid="stHorizontalBlock"] {
|
||||
gap: 0.5rem !important;
|
||||
}
|
||||
/* ========== 隐藏整个页眉(含 Deploy、菜单、状态等) ========== */
|
||||
header[data-testid="stHeader"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ========== 隐藏右上角Deploy和菜单按钮 ========== */
|
||||
/* 隐藏 Streamlit 自带的 Deploy 按钮 */
|
||||
button[kind="header"]:has(div[data-testid="stStatusWidget"]),
|
||||
button[kind="headerNoBorder"]:has(div[data-testid="stStatusWidget"]),
|
||||
|
||||
/* 隐藏右上角的...菜单按钮 */
|
||||
[data-testid="stMainMenu"] > div > button,
|
||||
.stMainMenu,
|
||||
|
||||
/* 隐藏状态图标 */
|
||||
[data-testid="stStatusWidget"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 补偿页眉高度 */
|
||||
.appview-container .main {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
</style>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
[project]
|
||||
name = "aigeotools"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"dashscope>=1.25.19",
|
||||
"langchain-core>=1.4.0",
|
||||
"langchain-deepseek>=1.0.1",
|
||||
"langchain-groq>=1.1.2",
|
||||
"langchain-moonshot>=0.1.0",
|
||||
"langchain-openai>=1.2.2",
|
||||
"pandas>=3.0.3",
|
||||
"plotly>=6.7.0",
|
||||
"pyperclip>=1.11.0",
|
||||
"streamlit>=1.58.0",
|
||||
"tomli-w>=1.2.0",
|
||||
"watchdog>=6.0.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["build_scripts*"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pyinstaller>=6.20.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = true
|
||||
|
||||
[project.scripts]
|
||||
python-build = "build_scripts.build:main"
|
||||
tauri-run = "build_scripts.runtauri:main"
|
||||
tauri-build = "build_scripts.build:build_tauri"
|
||||
@@ -1,35 +0,0 @@
|
||||
# 核心依赖
|
||||
streamlit>=1.30,<2
|
||||
pandas>=2.0,<3
|
||||
plotly>=5.0,<6
|
||||
|
||||
# LangChain 核心
|
||||
langchain-core>=1.2,<2
|
||||
langchain-community>=0.4,<1
|
||||
|
||||
# LangChain 提供商(按需安装)
|
||||
langchain-openai>=1.1,<2
|
||||
langchain-groq>=1.1,<2
|
||||
langchain-deepseek>=1.0,<2
|
||||
langchain-moonshot>=0.1.0,<1
|
||||
langchain-aliyun>=0.1.0,<1
|
||||
|
||||
# 平台同步功能依赖
|
||||
httpx>=0.24.0
|
||||
pyperclip>=1.8.2
|
||||
|
||||
# ============================================================
|
||||
# 可选依赖(根据需要取消注释)
|
||||
# ============================================================
|
||||
|
||||
# 通义万相图片生成(阿里云)
|
||||
# dashscope>=1.0,<2
|
||||
|
||||
# 文心一言(百度)
|
||||
# qianfan>=0.1.0
|
||||
|
||||
# 豆包(字节跳动)
|
||||
# pip install 'volcengine-python-sdk[ark]'
|
||||
|
||||
# LangChain 文心一言支持
|
||||
# langchain-wenxin>=0.1.0,<1
|
||||
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
GEO Tool — PyInstaller 打包入口
|
||||
|
||||
打包后自动切换工作目录到解压路径,确保 config.json、geo_data.db、
|
||||
.streamlit/、knowledge_base/ 等资源文件的相对路径正常工作。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
# 判断是否在 PyInstaller 打包环境中运行
|
||||
if getattr(sys, "frozen", False):
|
||||
bundle_dir = Path(sys._MEIPASS)
|
||||
else:
|
||||
bundle_dir = Path(__file__).parent
|
||||
|
||||
# 切换到打包目录,确保所有相对路径能正确解析
|
||||
os.chdir(str(bundle_dir))
|
||||
|
||||
# 确保运行时目录存在(打包时可能为空目录导致不被包含)
|
||||
(bundle_dir / "knowledge_base").mkdir(exist_ok=True)
|
||||
|
||||
# 重写 argv 启动 Streamlit
|
||||
# 注意:此应用将被嵌入桌面壳(Tauri)中运行,因此:
|
||||
# - headless=true:不自动打开浏览器,由桌面壳接管
|
||||
# - port=8501:固定端口
|
||||
# - gatherUsageStats=false / developmentMode=false:关闭遥测与在线服务
|
||||
# - logger.level=error:静默启动,减少控制台输出
|
||||
sys.argv = [
|
||||
"streamlit",
|
||||
"run",
|
||||
"geo_tool.py",
|
||||
"--server.port", "8501",
|
||||
"--server.address", "127.0.0.1",
|
||||
"--server.headless", "true",
|
||||
"--browser.gatherUsageStats", "false",
|
||||
"--global.developmentMode", "false",
|
||||
"--logger.level", "error",
|
||||
]
|
||||
|
||||
from streamlit.web.cli import main as streamlit_main
|
||||
streamlit_main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
repository = ""
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
name = "app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.5.3", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
tauri = { version = "2.9.5", features = [] }
|
||||
tauri-plugin-log = "2"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
@@ -0,0 +1,167 @@
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
use tauri::Manager;
|
||||
|
||||
struct StreamlitProcess(Mutex<Option<Child>>);
|
||||
|
||||
/// 等待指定端口可连接,最多等待 timeout_secs 秒
|
||||
fn wait_for_port(host: &str, port: u16, timeout_secs: u64) -> bool {
|
||||
let start = std::time::Instant::now();
|
||||
while start.elapsed() < std::time::Duration::from_secs(timeout_secs) {
|
||||
if std::net::TcpStream::connect((host, port)).is_ok() {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 停止 Streamlit 子进程
|
||||
///
|
||||
/// - Unix: 先 SIGTERM(优雅退出),等待 3 秒无响应后 SIGKILL
|
||||
/// - Windows: 直接 TerminateProcess
|
||||
fn kill_streamlit(child: &mut Child) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// 先发 SIGTERM 请求优雅退出
|
||||
let _ = Command::new("kill")
|
||||
.arg("-TERM")
|
||||
.arg(child.id().to_string())
|
||||
.spawn();
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
|
||||
// 尝试 wait(如果已退出则返回 Ok(Some(status)))
|
||||
if let Ok(Some(_)) = child.try_wait() {
|
||||
log::info!("Streamlit 进程已优雅退出");
|
||||
return;
|
||||
}
|
||||
log::warn!("Streamlit 进程未响应 SIGTERM,强制终止...");
|
||||
}
|
||||
|
||||
// 强制终止
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let app = tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Info)
|
||||
.build(),
|
||||
)?;
|
||||
}
|
||||
|
||||
let child = if cfg!(debug_assertions) {
|
||||
// ── 开发模式 ── 使用系统安装的 streamlit ──
|
||||
let manifest_dir =
|
||||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let project_root = manifest_dir.parent().unwrap_or(&manifest_dir);
|
||||
let script_path = project_root.join("geo_tool.py");
|
||||
|
||||
log::info!(
|
||||
"[开发模式] 启动 Streamlit: {:?}",
|
||||
script_path
|
||||
);
|
||||
|
||||
Command::new("streamlit")
|
||||
.args([
|
||||
"run",
|
||||
script_path.to_str().unwrap_or("geo_tool.py"),
|
||||
"--server.port",
|
||||
"8501",
|
||||
"--server.address",
|
||||
"127.0.0.1",
|
||||
"--server.headless",
|
||||
"true",
|
||||
"--browser.gatherUsageStats",
|
||||
"false",
|
||||
"--logger.level",
|
||||
"error",
|
||||
])
|
||||
.current_dir(project_root)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
} else {
|
||||
// ── 生产模式 ── 使用 PyInstaller 打包的独立可执行文件 ──
|
||||
let resource_dir = app
|
||||
.path()
|
||||
.resource_dir()
|
||||
.expect("无法获取资源目录");
|
||||
|
||||
let exe_name = if cfg!(target_os = "windows") {
|
||||
"geo_tool_app.exe"
|
||||
} else {
|
||||
"geo_tool_app"
|
||||
};
|
||||
let exe_path = resource_dir.join("_up_").join("dist").join(exe_name);
|
||||
|
||||
log::info!(
|
||||
"[生产模式] 启动打包应用: {:?}",
|
||||
exe_path
|
||||
);
|
||||
|
||||
let mut cmd = Command::new(&exe_path);
|
||||
cmd.current_dir(&resource_dir)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
|
||||
// Windows: 不显示控制台窗口
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000u32;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
cmd.spawn()
|
||||
};
|
||||
|
||||
match child {
|
||||
Ok(child) => {
|
||||
log::info!("Streamlit 进程已启动 (PID: {})", child.id());
|
||||
app.manage(StreamlitProcess(Mutex::new(Some(child))));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("启动 Streamlit 失败: {}", e);
|
||||
eprintln!(
|
||||
"错误:无法启动 Streamlit 服务。请确保已安装 streamlit。\n {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 等待 Streamlit 就绪(最多 15 秒)
|
||||
if !wait_for_port("127.0.0.1", 8501, 15) {
|
||||
log::warn!("Streamlit 在 15 秒内未能就绪");
|
||||
} else {
|
||||
log::info!("Streamlit 已就绪 (127.0.0.1:8501)");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application");
|
||||
|
||||
app.run(|app_handle, event| {
|
||||
// 窗口关闭 → 停止子进程 → 退出应用
|
||||
if let tauri::RunEvent::ExitRequested { .. } = event {
|
||||
if let Some(state) = app_handle.try_state::<StreamlitProcess>() {
|
||||
if let Ok(mut guard) = state.0.lock() {
|
||||
if let Some(ref mut child) = *guard {
|
||||
log::info!("正在停止 Streamlit 进程...");
|
||||
kill_streamlit(child);
|
||||
log::info!("Streamlit 进程已停止");
|
||||
}
|
||||
}
|
||||
}
|
||||
// 确保应用进程完全退出
|
||||
std::process::exit(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
use tauri::Manager;
|
||||
|
||||
struct StreamlitProcess(Mutex<Option<Child>>);
|
||||
|
||||
/// 等待指定端口可连接,最多等待 timeout_secs 秒
|
||||
fn wait_for_port(host: &str, port: u16, timeout_secs: u64) -> bool {
|
||||
let start = std::time::Instant::now();
|
||||
while start.elapsed() < std::time::Duration::from_secs(timeout_secs) {
|
||||
if std::net::TcpStream::connect((host, port)).is_ok() {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 停止 Streamlit 子进程
|
||||
///
|
||||
/// - Unix: 先 SIGTERM(优雅退出),等待 3 秒无响应后 SIGKILL
|
||||
/// - Windows: 直接 TerminateProcess
|
||||
fn kill_streamlit(child: &mut Child) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// 先发 SIGTERM 请求优雅退出
|
||||
let _ = Command::new("kill")
|
||||
.arg("-TERM")
|
||||
.arg(child.id().to_string())
|
||||
.spawn();
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
|
||||
// 尝试 wait(如果已退出则返回 Ok(Some(status)))
|
||||
if let Ok(Some(_)) = child.try_wait() {
|
||||
log::info!("Streamlit 进程已优雅退出");
|
||||
return;
|
||||
}
|
||||
log::warn!("Streamlit 进程未响应 SIGTERM,强制终止...");
|
||||
}
|
||||
|
||||
// 强制终止
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let app = tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Info)
|
||||
.build(),
|
||||
)?;
|
||||
}
|
||||
|
||||
let child = if cfg!(debug_assertions) {
|
||||
// ── 开发模式 ── 使用系统安装的 streamlit ──
|
||||
let manifest_dir =
|
||||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let project_root = manifest_dir.parent().unwrap_or(&manifest_dir);
|
||||
let script_path = project_root.join("geo_tool.py");
|
||||
|
||||
log::info!(
|
||||
"[开发模式] 启动 Streamlit: {:?}",
|
||||
script_path
|
||||
);
|
||||
|
||||
Command::new("streamlit")
|
||||
.args([
|
||||
"run",
|
||||
script_path.to_str().unwrap_or("geo_tool.py"),
|
||||
"--server.port",
|
||||
"8501",
|
||||
"--server.address",
|
||||
"127.0.0.1",
|
||||
"--server.headless",
|
||||
"true",
|
||||
"--browser.gatherUsageStats",
|
||||
"false",
|
||||
"--logger.level",
|
||||
"error",
|
||||
])
|
||||
.current_dir(project_root)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
} else {
|
||||
// ── 生产模式 ── 使用 PyInstaller 打包的独立可执行文件 ──
|
||||
let resource_dir = app
|
||||
.path()
|
||||
.resource_dir()
|
||||
.expect("无法获取资源目录");
|
||||
|
||||
let exe_name = if cfg!(target_os = "windows") {
|
||||
"geo_tool_app.exe"
|
||||
} else {
|
||||
"geo_tool_app"
|
||||
};
|
||||
let exe_path = resource_dir.join(exe_name);
|
||||
|
||||
log::info!(
|
||||
"[生产模式] 启动打包应用: {:?}",
|
||||
exe_path
|
||||
);
|
||||
|
||||
let mut cmd = Command::new(&exe_path);
|
||||
cmd.current_dir(&resource_dir)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
|
||||
// Windows: 不显示控制台窗口
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000u32;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
cmd.spawn()
|
||||
};
|
||||
|
||||
match child {
|
||||
Ok(child) => {
|
||||
log::info!("Streamlit 进程已启动 (PID: {})", child.id());
|
||||
app.manage(StreamlitProcess(Mutex::new(Some(child))));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("启动 Streamlit 失败: {}", e);
|
||||
eprintln!(
|
||||
"错误:无法启动 Streamlit 服务。请确保已安装 streamlit。\n {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 等待 Streamlit 就绪(最多 15 秒)
|
||||
if !wait_for_port("127.0.0.1", 8501, 15) {
|
||||
log::warn!("Streamlit 在 15 秒内未能就绪");
|
||||
} else {
|
||||
log::info!("Streamlit 已就绪 (127.0.0.1:8501)");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application");
|
||||
|
||||
app.run(|app_handle, event| {
|
||||
// 窗口关闭 → 停止子进程 → 退出应用
|
||||
if let tauri::RunEvent::ExitRequested { .. } = event {
|
||||
if let Some(state) = app_handle.try_state::<StreamlitProcess>() {
|
||||
if let Ok(mut guard) = state.0.lock() {
|
||||
if let Some(ref mut child) = *guard {
|
||||
log::info!("正在停止 Streamlit 进程...");
|
||||
kill_streamlit(child);
|
||||
log::info!("Streamlit 进程已停止");
|
||||
}
|
||||
}
|
||||
}
|
||||
// 确保应用进程完全退出
|
||||
std::process::exit(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
app_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "\u4e11\u6a58GEO",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.pinesound.geo-tool",
|
||||
"build": {
|
||||
"frontendDist": "http://localhost:8501",
|
||||
"devUrl": "http://localhost:8501"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "\u4e11\u6a58\u6587\u5316\u4f20\u5a92GEO\u5185\u5bb9\u4f18\u5316\u5e73\u53f0",
|
||||
"width": 1400,
|
||||
"height": 900,
|
||||
"minWidth": 1000,
|
||||
"minHeight": 700,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"resources": [
|
||||
"../dist_python/geo_tool_app"
|
||||
]
|
||||
}
|
||||
}
|
||||