♻️ refactor(config): 实现配置安全存储与原子写
- 新增 `get_secure()` 和 `set_secure()` 方法,优先从环境变量或系统 keyring 读取敏感配置,`config.json` 中仅存储占位符 - 将 `save()` 方法改为使用临时文件 + `os.replace()` 的原子写入,防止进程中断导致配置文件损坏 - 在 `add_llm_provider()` 和 `get_active_llm()` 中集成安全配置读写,自动迁移旧版明文 API Key ♻️ refactor(analytics): 实现分析数据原子写 - 将 `_save_analytics()` 和 `_save_weights()` 方法改为使用临时文件 + `os.replace()` 的原子写入 - 确保在写入过程中进程被终止时,原始数据文件保持完整 ♻️ refactor(main): 增强发布功能健壮性与代码模块化 - 在 `publish_to_xhs()` 中增加发布前输入校验【标题长度、图片数量、文件存在性】并在 `finally` 块中自动清理本次生成的临时图片文件 - 为全局笔记列表缓存 `_cached_proactive_entries` 和 `_cached_my_note_entries` 引入 `threading.RLock` 保护,新增 `_set_cache()` 和 `_get_cache()` 线程安全操作函数 - 将「内容创作」Tab 的 UI 构建代码拆分至 `ui/tab_create.py` 模块,主文件通过 `build_tab()` 函数调用并组装 - 将 Gradio 应用的 CSS 和主题配置提取为模块级变量,提升可维护性 📦 build(deps): 新增 keyring 依赖 - 在 `requirements.txt` 中添加 `keyring>=24.0.0` 以支持系统凭证管理 📝 docs(openspec): 新增生产就绪审计文档 - 在 `openspec/changes/archive/2026-02-24-production-readiness-audit/` 下新增设计文档、提案、任务清单及各功能规格说明 - 将核心功能规格同步至 `openspec/specs/` 目录
This commit is contained in:
parent
4cde2f7c67
commit
d88b4e9a3b
@ -5,6 +5,7 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
import logging
|
||||
import math
|
||||
@ -67,13 +68,35 @@ class AnalyticsService:
|
||||
|
||||
def _save_analytics(self):
|
||||
os.makedirs(self.workspace_dir, exist_ok=True)
|
||||
with open(self.analytics_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self._analytics_data, f, ensure_ascii=False, indent=2)
|
||||
target = self.analytics_path
|
||||
target_dir = os.path.dirname(os.path.abspath(target))
|
||||
fd, tmp = tempfile.mkstemp(dir=target_dir, suffix=".tmp", prefix="analytics_")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(self._analytics_data, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, target)
|
||||
except Exception:
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def _save_weights(self):
|
||||
os.makedirs(self.workspace_dir, exist_ok=True)
|
||||
with open(self.weights_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self._weights, f, ensure_ascii=False, indent=2)
|
||||
target = self.weights_path
|
||||
target_dir = os.path.dirname(os.path.abspath(target))
|
||||
fd, tmp = tempfile.mkstemp(dir=target_dir, suffix=".tmp", prefix="weights_")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(self._weights, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, target)
|
||||
except Exception:
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
# ========== 数据采集 ==========
|
||||
|
||||
|
||||
@ -4,10 +4,15 @@
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 敏感字段 keyring 存储常量
|
||||
_KEYRING_PLACEHOLDER = "[keyring]"
|
||||
_KEYRING_SERVICE = "autobot_xhs"
|
||||
|
||||
CONFIG_FILE = "config.json"
|
||||
OUTPUT_DIR = "xhs_workspace"
|
||||
|
||||
@ -88,13 +93,61 @@ class ConfigManager:
|
||||
return config
|
||||
|
||||
def save(self):
|
||||
"""保存配置到文件"""
|
||||
"""原子写:临时文件 + os.replace,防止写中断导致数据损坏"""
|
||||
config_dir = os.path.dirname(os.path.abspath(CONFIG_FILE)) or "."
|
||||
try:
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(self._config, f, indent=4, ensure_ascii=False)
|
||||
fd, tmp_path = tempfile.mkstemp(dir=config_dir, suffix=".tmp", prefix="config_")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(self._config, f, indent=4, ensure_ascii=False)
|
||||
os.replace(tmp_path, CONFIG_FILE)
|
||||
except Exception:
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
except IOError as e:
|
||||
logger.error("配置保存失败: %s", e)
|
||||
|
||||
def get_secure(self, key: str, default: str = "") -> str:
|
||||
"""读取敏感配置,优先级: 环境变量 AUTOBOT_<KEY> > keyring > config.json 明文(自动迁移)"""
|
||||
env_val = os.environ.get(f"AUTOBOT_{key.upper()}")
|
||||
if env_val:
|
||||
return env_val
|
||||
try:
|
||||
import keyring
|
||||
kr_val = keyring.get_password(_KEYRING_SERVICE, key)
|
||||
if kr_val is not None:
|
||||
return kr_val
|
||||
except Exception as e:
|
||||
logger.warning("keyring 读取失败,使用明文回退: %s", e)
|
||||
current = self._config.get(key, default)
|
||||
if current and current != _KEYRING_PLACEHOLDER:
|
||||
# 有明文值,尝试自动迁移到 keyring
|
||||
try:
|
||||
import keyring
|
||||
keyring.set_password(_KEYRING_SERVICE, key, current)
|
||||
self._config[key] = _KEYRING_PLACEHOLDER
|
||||
self.save()
|
||||
logger.info("已将 '%s' 自动迁移至系统 keyring", key)
|
||||
except Exception as e:
|
||||
logger.warning("keyring 迁移失败,继续使用明文: %s", e)
|
||||
return current
|
||||
return default
|
||||
|
||||
def set_secure(self, key: str, value: str):
|
||||
"""将敏感值存入 keyring(不可用时降级为明文),config.json 中写入占位符"""
|
||||
try:
|
||||
import keyring
|
||||
keyring.set_password(_KEYRING_SERVICE, key, value)
|
||||
self._config[key] = _KEYRING_PLACEHOLDER
|
||||
self.save()
|
||||
except Exception as e:
|
||||
logger.warning("keyring 不可用,明文保存 '%s': %s", key, e)
|
||||
self._config[key] = value
|
||||
self.save()
|
||||
|
||||
def get(self, key: str, default=None):
|
||||
"""获取配置项"""
|
||||
return self._config.get(key, default)
|
||||
@ -141,14 +194,24 @@ class ConfigManager:
|
||||
return [p["name"] for p in self.get_llm_providers()]
|
||||
|
||||
def get_active_llm(self) -> dict | None:
|
||||
"""获取当前激活的 LLM 提供商配置"""
|
||||
"""获取当前激活的 LLM 提供商配置(自动解析 keyring 中的 api_key)"""
|
||||
active_name = self._config.get("active_llm", "")
|
||||
for p in self.get_llm_providers():
|
||||
if p["name"] == active_name:
|
||||
return p
|
||||
# 没找到就返回第一个
|
||||
providers = self.get_llm_providers()
|
||||
return providers[0] if providers else None
|
||||
provider = None
|
||||
for p in providers:
|
||||
if p["name"] == active_name:
|
||||
provider = p
|
||||
break
|
||||
if provider is None:
|
||||
provider = providers[0] if providers else None
|
||||
if provider is None:
|
||||
return None
|
||||
# 解析 api_key:占位符时从 keyring 读取真实值
|
||||
api_key = provider.get("api_key", "")
|
||||
if api_key == _KEYRING_PLACEHOLDER:
|
||||
secure_key = f"provider_{provider['name']}_api_key"
|
||||
api_key = self.get_secure(secure_key, "")
|
||||
return {**provider, "api_key": api_key}
|
||||
|
||||
def add_llm_provider(self, name: str, api_key: str, base_url: str) -> str:
|
||||
"""添加一个 LLM 提供商,返回状态消息"""
|
||||
@ -161,9 +224,18 @@ class ConfigManager:
|
||||
for p in providers:
|
||||
if p["name"] == name:
|
||||
return f"❌ 名称「{name}」已存在,请换一个"
|
||||
# 安全存储 api_key
|
||||
stored_key = api_key.strip()
|
||||
secure_key = f"provider_{name}_api_key"
|
||||
try:
|
||||
import keyring
|
||||
keyring.set_password(_KEYRING_SERVICE, secure_key, stored_key)
|
||||
stored_key = _KEYRING_PLACEHOLDER
|
||||
except Exception as e:
|
||||
logger.warning("keyring 不可用,api_key 明文存储: %s", e)
|
||||
providers.append({
|
||||
"name": name,
|
||||
"api_key": api_key.strip(),
|
||||
"api_key": stored_key,
|
||||
"base_url": (base_url or "https://api.openai.com/v1").strip().rstrip("/"),
|
||||
})
|
||||
self._config["llm_providers"] = providers
|
||||
|
||||
288
main.py
288
main.py
@ -22,6 +22,7 @@ from llm_service import LLMService
|
||||
from sd_service import SDService, DEFAULT_NEGATIVE, FACE_IMAGE_PATH, SD_PRESET_NAMES, get_sd_preset, get_model_profile, get_model_profile_info, detect_model_profile, SD_MODEL_PROFILES
|
||||
from mcp_client import MCPClient, get_mcp_client
|
||||
from analytics_service import AnalyticsService
|
||||
from ui.tab_create import build_tab
|
||||
|
||||
# ================= matplotlib 中文字体配置 =================
|
||||
_font_candidates = ["Microsoft YaHei", "SimHei", "PingFang SC", "WenQuanYi Micro Hei"]
|
||||
@ -421,45 +422,55 @@ def one_click_export(title, content, images):
|
||||
|
||||
|
||||
def publish_to_xhs(title, content, tags_str, images, local_images, mcp_url, schedule_time):
|
||||
"""通过 MCP 发布到小红书"""
|
||||
"""通过 MCP 发布到小红书(含输入校验和临时文件自动清理)"""
|
||||
# === 发布前校验 ===
|
||||
if not title:
|
||||
return "❌ 缺少标题"
|
||||
if len(title) > 20:
|
||||
return f"❌ 标题超长:当前 {len(title)} 字,小红书限制 ≤20 字,请精简后再发布"
|
||||
|
||||
client = get_mcp_client(mcp_url)
|
||||
|
||||
# 收集图片路径
|
||||
image_paths = []
|
||||
|
||||
# 先保存 AI 生成的图片到临时目录
|
||||
if images:
|
||||
temp_dir = os.path.join(OUTPUT_DIR, "_temp_publish")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
for idx, img in enumerate(images):
|
||||
if isinstance(img, Image.Image):
|
||||
path = os.path.abspath(os.path.join(temp_dir, f"ai_{idx}.jpg"))
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
img.save(path, format="JPEG", quality=95)
|
||||
image_paths.append(path)
|
||||
|
||||
# 添加本地上传的图片
|
||||
if local_images:
|
||||
for img_file in local_images:
|
||||
# Gradio File 组件返回的是 NamedString 或 tempfile path
|
||||
img_path = img_file.name if hasattr(img_file, 'name') else str(img_file)
|
||||
if os.path.exists(img_path):
|
||||
image_paths.append(os.path.abspath(img_path))
|
||||
|
||||
if not image_paths:
|
||||
return "❌ 至少需要 1 张图片才能发布"
|
||||
|
||||
# 解析标签
|
||||
tags = [t.strip().lstrip("#") for t in tags_str.split(",") if t.strip()] if tags_str else None
|
||||
|
||||
# 定时发布
|
||||
schedule = schedule_time if schedule_time and schedule_time.strip() else None
|
||||
ai_temp_files: list = [] # 追踪本次写入的临时文件,用于 finally 清理
|
||||
|
||||
try:
|
||||
# 收集图片路径
|
||||
image_paths = []
|
||||
|
||||
# 先保存 AI 生成的图片到临时目录
|
||||
if images:
|
||||
temp_dir = os.path.join(OUTPUT_DIR, "_temp_publish")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
for idx, img in enumerate(images):
|
||||
if isinstance(img, Image.Image):
|
||||
path = os.path.abspath(os.path.join(temp_dir, f"ai_{idx}.jpg"))
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
img.save(path, format="JPEG", quality=95)
|
||||
image_paths.append(path)
|
||||
ai_temp_files.append(path) # 登记临时文件
|
||||
|
||||
# 添加本地上传的图片
|
||||
if local_images:
|
||||
for img_file in local_images:
|
||||
img_path = img_file.name if hasattr(img_file, 'name') else str(img_file)
|
||||
if os.path.exists(img_path):
|
||||
image_paths.append(os.path.abspath(img_path))
|
||||
|
||||
# === 图片校验 ===
|
||||
if not image_paths:
|
||||
return "❌ 至少需要 1 张图片才能发布"
|
||||
if len(image_paths) > 18:
|
||||
return f"❌ 图片数量超限:当前 {len(image_paths)} 张,小红书限制 ≤18 张,请减少图片"
|
||||
for p in image_paths:
|
||||
if not os.path.exists(p):
|
||||
return f"❌ 图片文件不存在:{p}"
|
||||
|
||||
# 解析标签
|
||||
tags = [t.strip().lstrip("#") for t in tags_str.split(",") if t.strip()] if tags_str else None
|
||||
|
||||
# 定时发布
|
||||
schedule = schedule_time if schedule_time and schedule_time.strip() else None
|
||||
|
||||
result = client.publish_content(
|
||||
title=title,
|
||||
content=content,
|
||||
@ -473,6 +484,14 @@ def publish_to_xhs(title, content, tags_str, images, local_images, mcp_url, sche
|
||||
except Exception as e:
|
||||
logger.error("发布失败: %s", e)
|
||||
return f"❌ 发布异常: {e}"
|
||||
finally:
|
||||
# 清理本次写入的 AI 临时图片(无论成功/失败)
|
||||
for tmp_path in ai_temp_files:
|
||||
try:
|
||||
if os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
except OSError as cleanup_err:
|
||||
logger.warning("临时文件清理失败 %s: %s", tmp_path, cleanup_err)
|
||||
|
||||
|
||||
# ==================================================
|
||||
@ -560,17 +579,36 @@ def generate_from_hotspot(model, topic_from_hotspot, style, search_result, sd_mo
|
||||
# Tab 3: 评论管家
|
||||
# ==================================================
|
||||
|
||||
# ---- 共用: 笔记列表缓存 ----
|
||||
# ---- 共用: 笔记列表缓存(线程安全)----
|
||||
|
||||
# 主动评论缓存
|
||||
_cached_proactive_entries: list[dict] = []
|
||||
# 我的笔记评论缓存
|
||||
_cached_my_note_entries: list[dict] = []
|
||||
# 缓存互斥锁,防止并发回调产生竞态
|
||||
_cache_lock = threading.RLock()
|
||||
|
||||
|
||||
def _set_cache(name: str, entries: list):
|
||||
"""线程安全地更新笔记列表缓存"""
|
||||
global _cached_proactive_entries, _cached_my_note_entries
|
||||
with _cache_lock:
|
||||
if name == "proactive":
|
||||
_cached_proactive_entries = list(entries)
|
||||
else:
|
||||
_cached_my_note_entries = list(entries)
|
||||
|
||||
|
||||
def _get_cache(name: str) -> list:
|
||||
"""线程安全地获取笔记列表缓存快照(返回副本)"""
|
||||
with _cache_lock:
|
||||
if name == "proactive":
|
||||
return list(_cached_proactive_entries)
|
||||
return list(_cached_my_note_entries)
|
||||
|
||||
|
||||
def _fetch_and_cache(keyword, mcp_url, cache_name="proactive"):
|
||||
"""通用: 获取笔记列表并缓存"""
|
||||
global _cached_proactive_entries, _cached_my_note_entries
|
||||
"""通用: 获取笔记列表并线程安全地缓存"""
|
||||
try:
|
||||
client = get_mcp_client(mcp_url)
|
||||
if keyword and keyword.strip():
|
||||
@ -580,10 +618,7 @@ def _fetch_and_cache(keyword, mcp_url, cache_name="proactive"):
|
||||
entries = client.list_feeds_parsed()
|
||||
src = "首页推荐"
|
||||
|
||||
if cache_name == "proactive":
|
||||
_cached_proactive_entries = entries
|
||||
else:
|
||||
_cached_my_note_entries = entries
|
||||
_set_cache(cache_name, entries)
|
||||
|
||||
if not entries:
|
||||
return gr.update(choices=[], value=None), f"⚠️ 从{src}未找到笔记"
|
||||
@ -599,16 +634,13 @@ def _fetch_and_cache(keyword, mcp_url, cache_name="proactive"):
|
||||
f"✅ 从{src}获取 {len(entries)} 条笔记",
|
||||
)
|
||||
except Exception as e:
|
||||
if cache_name == "proactive":
|
||||
_cached_proactive_entries = []
|
||||
else:
|
||||
_cached_my_note_entries = []
|
||||
_set_cache(cache_name, [])
|
||||
return gr.update(choices=[], value=None), f"❌ {e}"
|
||||
|
||||
|
||||
def _pick_from_cache(selected, cache_name="proactive"):
|
||||
"""通用: 从缓存中提取选中条目的 feed_id / xsec_token / title"""
|
||||
cache = _cached_proactive_entries if cache_name == "proactive" else _cached_my_note_entries
|
||||
"""通用: 从缓存中提取选中条目的 feed_id / xsec_token / title(线程安全快照)"""
|
||||
cache = _get_cache(cache_name)
|
||||
if not selected or not cache:
|
||||
return "", "", ""
|
||||
try:
|
||||
@ -699,7 +731,6 @@ def send_comment(feed_id, xsec_token, comment_content, mcp_url):
|
||||
|
||||
def fetch_my_notes(mcp_url):
|
||||
"""通过已保存的 userId 获取我的笔记列表"""
|
||||
global _cached_my_note_entries
|
||||
my_uid = cfg.get("my_user_id", "")
|
||||
xsec = cfg.get("xsec_token", "")
|
||||
if not my_uid:
|
||||
@ -757,7 +788,7 @@ def fetch_my_notes(mcp_url):
|
||||
"type": nc.get("type", ""),
|
||||
})
|
||||
|
||||
_cached_my_note_entries = entries
|
||||
_set_cache("my_notes", entries)
|
||||
choices = [
|
||||
f"[{i+1}] {e['title'][:20]} | {e['type']} | ❤{e['likes']}"
|
||||
for i, e in enumerate(entries)
|
||||
@ -3003,14 +3034,14 @@ def toggle_autostart(enabled: bool) -> str:
|
||||
|
||||
config = cfg.all
|
||||
|
||||
with gr.Blocks(
|
||||
title="小红书 AI 爆文工坊 V2.0",
|
||||
theme=gr.themes.Soft(),
|
||||
css="""
|
||||
_GRADIO_CSS = """
|
||||
.status-ok { color: #16a34a; font-weight: bold; }
|
||||
.status-err { color: #dc2626; font-weight: bold; }
|
||||
footer { display: none !important; }
|
||||
""",
|
||||
"""
|
||||
|
||||
with gr.Blocks(
|
||||
title="小红书 AI 爆文工坊 V2.0",
|
||||
) as app:
|
||||
gr.Markdown(
|
||||
"# 🍒 小红书 AI 爆文生产工坊 V2.0\n"
|
||||
@ -3018,7 +3049,6 @@ with gr.Blocks(
|
||||
)
|
||||
|
||||
# 全局状态
|
||||
state_images = gr.State([])
|
||||
state_search_result = gr.State("")
|
||||
|
||||
# ============ 全局设置栏 ============
|
||||
@ -3132,71 +3162,36 @@ with gr.Blocks(
|
||||
|
||||
# ============ Tab 页面 ============
|
||||
with gr.Tabs():
|
||||
# -------- Tab 1: 内容创作 --------
|
||||
with gr.Tab("✨ 内容创作"):
|
||||
with gr.Row():
|
||||
# 左栏:输入
|
||||
with gr.Column(scale=1):
|
||||
gr.Markdown("### 💡 构思")
|
||||
topic = gr.Textbox(label="笔记主题", placeholder="例如:优衣库早春穿搭")
|
||||
style = gr.Dropdown(
|
||||
DEFAULT_STYLES,
|
||||
label="风格", value="好物种草",
|
||||
)
|
||||
btn_gen_copy = gr.Button("✨ 第一步:生成文案", variant="primary")
|
||||
|
||||
gr.Markdown("---")
|
||||
gr.Markdown("### 🎨 绘图参数")
|
||||
quality_mode = gr.Radio(
|
||||
SD_PRESET_NAMES,
|
||||
label="生成模式",
|
||||
value=config.get("quality_mode", "标准 (约1分钟)"),
|
||||
info="快速≈30s 标准≈1min 精细≈2-3min (SDXL)",
|
||||
)
|
||||
with gr.Accordion("高级设置 (覆盖预设)", open=False):
|
||||
neg_prompt = gr.Textbox(
|
||||
label="反向提示词",
|
||||
value=config.get("sd_negative_prompt", DEFAULT_NEGATIVE),
|
||||
lines=2,
|
||||
)
|
||||
steps = gr.Slider(8, 50, value=config.get("sd_steps", 20), step=1, label="步数")
|
||||
cfg_scale = gr.Slider(1, 15, value=config.get("sd_cfg_scale", 5.5), step=0.5, label="CFG Scale")
|
||||
btn_gen_img = gr.Button("🎨 第二步:生成图片", variant="primary")
|
||||
|
||||
# 中栏:文案编辑
|
||||
with gr.Column(scale=1):
|
||||
gr.Markdown("### 📝 文案编辑")
|
||||
res_title = gr.Textbox(label="标题 (≤20字)", interactive=True)
|
||||
res_content = gr.TextArea(
|
||||
label="正文 (可手动修改)", lines=12, interactive=True,
|
||||
)
|
||||
res_prompt = gr.TextArea(
|
||||
label="绘图提示词", lines=3, interactive=True,
|
||||
)
|
||||
res_tags = gr.Textbox(
|
||||
label="话题标签 (逗号分隔)", interactive=True,
|
||||
placeholder="穿搭, 春季, 好物种草",
|
||||
)
|
||||
|
||||
# 右栏:预览 & 发布
|
||||
with gr.Column(scale=1):
|
||||
gr.Markdown("### 🖼️ 视觉预览")
|
||||
gallery = gr.Gallery(label="AI 生成图片", columns=2, height=300)
|
||||
local_images = gr.File(
|
||||
label="📁 上传本地图片(可混排)",
|
||||
file_count="multiple",
|
||||
file_types=["image"],
|
||||
)
|
||||
|
||||
gr.Markdown("### 🚀 发布")
|
||||
schedule_time = gr.Textbox(
|
||||
label="定时发布 (可选, ISO8601格式)",
|
||||
placeholder="如 2026-02-08T18:00:00+08:00,留空=立即发布",
|
||||
)
|
||||
with gr.Row():
|
||||
btn_export = gr.Button("📂 导出本地", variant="secondary")
|
||||
btn_publish = gr.Button("🚀 发布到小红书", variant="primary")
|
||||
publish_msg = gr.Markdown("")
|
||||
# -------- Tab 1: 内容创作(迁移至 ui/tab_create.py)--------
|
||||
_tab1 = build_tab(
|
||||
config=config,
|
||||
styles=DEFAULT_STYLES,
|
||||
sd_preset_names=SD_PRESET_NAMES,
|
||||
default_negative=DEFAULT_NEGATIVE,
|
||||
llm_model=llm_model,
|
||||
sd_model=sd_model,
|
||||
sd_url=sd_url,
|
||||
persona=persona,
|
||||
status_bar=status_bar,
|
||||
face_swap_toggle=face_swap_toggle,
|
||||
face_image_preview=face_image_preview,
|
||||
mcp_url=mcp_url,
|
||||
fn_gen_copy=generate_copy,
|
||||
fn_gen_img=generate_images,
|
||||
fn_export=one_click_export,
|
||||
fn_publish=publish_to_xhs,
|
||||
fn_get_sd_preset=get_sd_preset,
|
||||
fn_cfg_set=cfg.set,
|
||||
fn_cfg_update=cfg.update,
|
||||
)
|
||||
res_title = _tab1["res_title"]
|
||||
res_content = _tab1["res_content"]
|
||||
res_prompt = _tab1["res_prompt"]
|
||||
res_tags = _tab1["res_tags"]
|
||||
quality_mode = _tab1["quality_mode"]
|
||||
steps = _tab1["steps"]
|
||||
cfg_scale = _tab1["cfg_scale"]
|
||||
neg_prompt = _tab1["neg_prompt"]
|
||||
|
||||
# -------- Tab 2: 热点探测 --------
|
||||
with gr.Tab("🔥 热点探测"):
|
||||
@ -3894,59 +3889,6 @@ with gr.Blocks(
|
||||
outputs=[face_image_preview, face_status],
|
||||
)
|
||||
|
||||
# ---- Tab 1: 内容创作 ----
|
||||
btn_gen_copy.click(
|
||||
fn=generate_copy,
|
||||
inputs=[llm_model, topic, style, sd_model, persona],
|
||||
outputs=[res_title, res_content, res_prompt, res_tags, status_bar],
|
||||
)
|
||||
|
||||
# 生成模式切换 → 同步更新步数/CFG预览
|
||||
def on_quality_mode_change(mode, sd_model_val):
|
||||
p = get_sd_preset(mode, sd_model_val)
|
||||
return p["steps"], p["cfg_scale"]
|
||||
|
||||
# 保存图片生成参数到配置
|
||||
def save_quality_mode(mode):
|
||||
cfg.set("quality_mode", mode)
|
||||
p = get_sd_preset(mode)
|
||||
return p["steps"], p["cfg_scale"]
|
||||
|
||||
def save_sd_params(s, c, n):
|
||||
cfg.update({"sd_steps": s, "sd_cfg_scale": c, "sd_negative_prompt": n})
|
||||
return None
|
||||
|
||||
quality_mode.change(
|
||||
fn=save_quality_mode,
|
||||
inputs=[quality_mode],
|
||||
outputs=[steps, cfg_scale],
|
||||
)
|
||||
|
||||
# 保存高级参数
|
||||
steps.change(fn=lambda s: cfg.set("sd_steps", s), inputs=[steps], outputs=[])
|
||||
cfg_scale.change(fn=lambda c: cfg.set("sd_cfg_scale", c), inputs=[cfg_scale], outputs=[])
|
||||
neg_prompt.change(fn=lambda n: cfg.set("sd_negative_prompt", n), inputs=[neg_prompt], outputs=[])
|
||||
|
||||
btn_gen_img.click(
|
||||
fn=generate_images,
|
||||
inputs=[sd_url, res_prompt, neg_prompt, sd_model, steps, cfg_scale,
|
||||
face_swap_toggle, face_image_preview, quality_mode, persona],
|
||||
outputs=[gallery, state_images, status_bar],
|
||||
)
|
||||
|
||||
btn_export.click(
|
||||
fn=one_click_export,
|
||||
inputs=[res_title, res_content, state_images],
|
||||
outputs=[publish_msg],
|
||||
)
|
||||
|
||||
btn_publish.click(
|
||||
fn=publish_to_xhs,
|
||||
inputs=[res_title, res_content, res_tags, state_images,
|
||||
local_images, mcp_url, schedule_time],
|
||||
outputs=[publish_msg],
|
||||
)
|
||||
|
||||
# ---- Tab 2: 热点探测 ----
|
||||
btn_search.click(
|
||||
fn=search_hotspots,
|
||||
@ -4412,4 +4354,6 @@ if __name__ == "__main__":
|
||||
inbrowser=True,
|
||||
share=False,
|
||||
auth=(_auth_user, _auth_pass),
|
||||
theme=gr.themes.Soft(),
|
||||
css=_GRADIO_CSS,
|
||||
)
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-24
|
||||
@ -0,0 +1,85 @@
|
||||
## Context
|
||||
|
||||
当前项目是一个单机运行的小红书 AI 爆文自动化工具,使用 Gradio 作为 UI 框架,SQLite 为发布队列持久化,JSON 文件存储配置与分析数据。随着功能迭代至 V2.0,主文件 `main.py` 已超过 4400 行,并积累了多处生产风险:API Key 以明文写入 `config.json`、全局列表变量被多个回调线程无锁读写、`_temp_publish/` 目录的临时图片在发布后未清理、JSON 文件采用直接覆盖写入(电量耗尽或进程中断时会写出空文件)、发布前无标题/正文/图片数量的校验。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 消除 6 项已知的生产风险,使项目能安全、稳定地长期自动运营
|
||||
- 保持所有 Gradio 回调函数签名不变(不破坏现有 UI 绑定)
|
||||
- 每项改动独立可部署,不相互耦合
|
||||
- 为后续功能拓展提供更清晰的代码结构基础
|
||||
|
||||
**Non-Goals:**
|
||||
- 重写为 Web 服务或引入数据库 ORM
|
||||
- 改变现有 UI 交互逻辑或视觉设计
|
||||
- 实现性能优化或多账号支持
|
||||
- 修改 MCP / SD / LLM 服务接口
|
||||
|
||||
## Decisions
|
||||
|
||||
### 决策 1:敏感配置存储方案选 keyring + 环境变量覆盖,放弃纯加密文件
|
||||
|
||||
**选项 A(选定)**: 使用 `keyring` 库将 API Key 存入系统凭证管理器(Windows Credential Manager / macOS Keychain / Linux Secret Service),`config.json` 中仅保留占位符 `"[keyring]"`;同时支持 `AUTOBOT_API_KEY_<NAME>` 环境变量在无 GUI 场景(Docker / CI)下覆盖。
|
||||
|
||||
**选项 B(放弃)**: 自行用 `Fernet` 加密后写入 `config.json`。缺点:密钥仍需本地存储,安全提升有限,且增加密钥管理复杂度。
|
||||
|
||||
**选项 C(放弃)**: 要求用户全部改用环境变量。对当前 Gradio UI 用户体验极差,用户每次重启需重新设置。
|
||||
|
||||
**结论**:`keyring` 方案对 Windows 单机用户最为透明,且 Docker 场景通过环境变量无缝降级。新增 `ConfigManager.get_secure(key)` / `set_secure(key, value)` 接口,内部优先读取环境变量,其次读 keyring,最后回退旧版明文(自动迁移一次)。
|
||||
|
||||
---
|
||||
|
||||
### 决策 2:全局缓存改用模块级 `threading.RLock` 保护,不引入新类
|
||||
|
||||
**选项 A(选定)**: 在 `main.py` 模块顶层声明一个 `_cache_lock = threading.RLock()`,所有读写 `_cached_proactive_entries` / `_cached_my_note_entries` 的函数用 `with _cache_lock:` 包裹。
|
||||
|
||||
**选项 B(放弃)**: 封装为 `CacheManager` 类。当前代码耦合 Gradio UI 较深,引入类会导致较大重构,收益不成比例。
|
||||
|
||||
**结论**:最小侵入方案,改动 5 处函数约 20 行,可在一个 PR 内完成。
|
||||
|
||||
---
|
||||
|
||||
### 决策 3:JSON 原子写使用 tempfile + os.replace
|
||||
|
||||
Python 标准库 `tempfile.NamedTemporaryFile` + `os.replace`(同目录)在 POSIX 和 Windows 均为原子操作(Windows Vista+ 支持)。无需引入新依赖。
|
||||
|
||||
适用范围:`ConfigManager.save()`、`AnalyticsService._save_analytics()`、`AnalyticsService._save_weights()`。
|
||||
|
||||
---
|
||||
|
||||
### 决策 4:临时文件清理在发布回调内同步执行
|
||||
|
||||
在 `publish_to_xhs()` 函数的 try/finally 块中清理 `_temp_publish/` 下以本次调用 `ai_N.jpg` 命名的文件。不使用全目录清空,以免并发发布时误删其他会话文件(Gradio 多用户虽少见,但更安全)。逐文件删除,删除失败仅打印 warning,不阻断流程。
|
||||
|
||||
---
|
||||
|
||||
### 决策 5:发布校验集中在 `publish_to_xhs()` 一处,不分散到 UI
|
||||
|
||||
校验逻辑写在业务函数中,而非 Gradio 的 `gr.Textbox` 校验器,保证逻辑可测试,且 UI 重构时不会遗失。
|
||||
|
||||
---
|
||||
|
||||
### 决策 6:UI 拆分采用渐进式迁移,不一次性重写
|
||||
|
||||
以 `ui/` 目录为目标,先提取最大的 Tab(内容创作 Tab)为 `ui/tab_create.py`,返回 `(components, callbacks)` 元组,`main.py` 调用并注册。其余 Tab 在后续迭代中逐步迁移。本次变更只迁移 `ui/tab_create.py` 作为示范,不强制完成全部拆分。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| 风险 | 缓解措施 |
|
||||
|------|---------|
|
||||
| `keyring` 在部分 Linux headless 环境无后端可用 | 检测到 `keyring.errors.NoKeyringError` 时降级为明文(打印警告),行为与改造前一致 |
|
||||
| `os.replace` 在跨卷(不同磁盘分区)时失败 | 使用 `tempfile.mkstemp(dir=same_dir)` 确保临时文件与目标同卷 |
|
||||
| 并发发布时 `ai_N.jpg` 命名冲突 | 当前 Gradio 为单进程单用户;若未来支持多用户,改用 UUID 命名 |
|
||||
| UI 拆分期间双重维护 | 每次只迁移一个 Tab,迁移完成前旧代码仍有效 |
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. **无需数据迁移**:`config.json` 的旧明文 Key 在首次调用 `get_secure()` 时自动读取并写入 keyring,同时将 `config.json` 中对应字段替换为 `"[keyring]"` 占位符,单次完成。
|
||||
2. **依赖安装**:`pip install keyring` 并更新 `requirements.txt`。
|
||||
3. **无回滚风险**:各项改动均向后兼容,若 keyring 不可用则自动回退明文模式。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 是否需要在 UI 上增加「导出加密备份配置」功能,方便用户迁移设备?(本次范围外,记录待后续评估)
|
||||
- `ui/` 拆分后是否需要引入 pytest-gradio 进行 UI 层单元测试?(本次不实施)
|
||||
@ -0,0 +1,39 @@
|
||||
## Why
|
||||
|
||||
当前项目已具备完整的核心功能(文案生成、图片绘制、发布队列、评论管家、数据分析),但在安全性、健壮性、可维护性上存在若干生产级隐患:配置文件明文保存 API Key、全局可变状态缺乏线程锁、临时文件无清理机制、单文件超 4400 行难以维护。这些问题会在长期运营中导致数据泄露、并发竞态或内存增长,亟需在持续迭代前统一修复。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **安全加固**:`config.json` 中的 API Key 等敏感字段改用操作系统 keyring / 环境变量存储,文件中只保留非敏感配置
|
||||
- **线程安全**:全局笔记缓存 (`_cached_proactive_entries` / `_cached_my_note_entries`) 及 `ConfigManager` 写操作加 `threading.Lock`
|
||||
- **临时文件清理**:发布完成或失败后自动清理 `_temp_publish/` 目录下的临时图片
|
||||
- **原子写文件**:`analytics_service.py` 和 `config_manager.py` 的 JSON 持久化改为「写临时文件 → rename」方式,防止写中断导致数据损坏
|
||||
- **发布前输入校验**:标题长度(≤20字)、正文长度、图片数量(1-18张)在提交发布前统一校验并给出明确提示
|
||||
- **代码拆分**:将 `main.py` 的 Gradio UI 构建与业务逻辑分离,拆分为 `ui/` 目录下的多个 tab 模块,主文件只负责组装
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `secure-config`:安全配置管理——敏感字段加密/外置存储,支持环境变量覆盖
|
||||
- `thread-safe-cache`:线程安全的笔记列表缓存管理器,替换全局裸列表
|
||||
- `temp-file-lifecycle`:临时发布文件的自动生命周期管理(创建→使用→清理)
|
||||
- `atomic-persistence`:JSON 持久化原子写操作,防止文件损坏
|
||||
- `publish-input-validation`:发布前内容合规校验(长度/图片数/必填项)
|
||||
- `ui-module-split`:将 `main.py` UI 构建逻辑拆分为独立 tab 模块
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
(无现有 spec,首次建立规范)
|
||||
|
||||
## Impact
|
||||
|
||||
- **`config_manager.py`**:`save()` 方法改为原子写;新增 `get_secure()` / `set_secure()` 接口
|
||||
- **`analytics_service.py`**:`_save_analytics()` / `_save_weights()` 改为原子写
|
||||
- **`publish_queue.py`**:无需修改(已使用 SQLite WAL,自身较健壮)
|
||||
- **`main.py`**:
|
||||
- 全局缓存变量引入 `threading.Lock`
|
||||
- `publish_to_xhs()` 增加校验逻辑与 temp 清理
|
||||
- UI 构建代码逐步迁移至 `ui/tab_*.py`
|
||||
- **`requirements.txt`**:可能新增 `keyring` 依赖
|
||||
- **无破坏性 API 变更**:所有 Gradio 回调签名保持不变
|
||||
@ -0,0 +1,16 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: JSON 文件写入使用原子操作
|
||||
`ConfigManager.save()`、`AnalyticsService._save_analytics()` 和 `AnalyticsService._save_weights()` SHALL 使用「写临时文件 → `os.replace()` 原子重命名」的方式持久化数据。临时文件 SHALL 创建于与目标文件相同的目录(同卷),以确保 `os.replace()` 的原子性。
|
||||
|
||||
#### Scenario: 写入过程中进程中断不产生损坏文件
|
||||
- **WHEN** JSON 写入过程中进程被强制终止
|
||||
- **THEN** 目标文件保持写入前的完整状态,不出现空文件或半写入的 JSON
|
||||
|
||||
#### Scenario: 正常写入成功替换目标文件
|
||||
- **WHEN** `ConfigManager.save()` 被调用且数据合法
|
||||
- **THEN** 目标 `config.json` 被更新为最新内容,写入前存在的临时文件已被清理
|
||||
|
||||
#### Scenario: 临时文件与目标文件在同一目录
|
||||
- **WHEN** 调用任意原子写函数
|
||||
- **THEN** 临时文件的父目录与目标文件的父目录相同(通过 `tempfile.mkstemp(dir=<target_dir>)` 实现)
|
||||
@ -0,0 +1,30 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 发布前校验标题、正文和图片
|
||||
`publish_to_xhs()` 函数 SHALL 在调用 MCP 发布接口前执行以下校验,任何校验失败 SHALL 立即返回包含明确说明的错误消息字符串,不发起网络请求:
|
||||
|
||||
| 字段 | 规则 |
|
||||
|------|------|
|
||||
| 标题 | 非空,长度 ≤ 20 个字符(中英文均按 1 字符计) |
|
||||
| 图片数量 | 至少 1 张,至多 18 张 |
|
||||
| 图片文件 | 每个路径对应的文件在磁盘上真实存在 |
|
||||
|
||||
#### Scenario: 标题超长时返回明确错误
|
||||
- **WHEN** `publish_to_xhs()` 被调用且标题字符数超过 20
|
||||
- **THEN** 返回包含「标题超长」提示及当前字符数的错误字符串,不调用 MCP 接口
|
||||
|
||||
#### Scenario: 无图片时返回明确错误
|
||||
- **WHEN** `publish_to_xhs()` 被调用且最终收集到的图片路径列表为空
|
||||
- **THEN** 返回「至少需要 1 张图片」的错误字符串
|
||||
|
||||
#### Scenario: 图片数量超限时返回明确错误
|
||||
- **WHEN** 最终图片路径列表超过 18 张
|
||||
- **THEN** 返回包含当前图片数和限制数的错误字符串,不发起发布请求
|
||||
|
||||
#### Scenario: 图片文件不存在时返回明确错误
|
||||
- **WHEN** 图片路径列表中有路径对应的文件不存在于磁盘
|
||||
- **THEN** 返回包含该文件路径的「文件不存在」错误字符串
|
||||
|
||||
#### Scenario: 校验通过后正常发布
|
||||
- **WHEN** 所有字段均通过校验
|
||||
- **THEN** 正常调用 MCP 接口发布,行为与改造前一致
|
||||
@ -0,0 +1,20 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 敏感字段通过系统 keyring 或环境变量存储
|
||||
ConfigManager SHALL 提供 `get_secure(key: str) -> str` 和 `set_secure(key: str, value: str)` 接口,用于读写需要保护的配置项(API Key 等)。读取优先级:环境变量 `AUTOBOT_<KEY>` > 系统 keyring > `config.json` 明文(兼容旧版,读取后自动迁移)。`config.json` 中已迁移的字段值替换为占位符字符串 `"[keyring]"`。
|
||||
|
||||
#### Scenario: 首次读取明文 API Key 时自动迁移
|
||||
- **WHEN** 调用 `get_secure("api_key")` 且 `config.json` 中该字段为普通字符串(非占位符)
|
||||
- **THEN** 系统将该值写入 keyring,将 `config.json` 中该字段更新为 `"[keyring]"`,并返回原始值
|
||||
|
||||
#### Scenario: keyring 不可用时降级为明文
|
||||
- **WHEN** 系统 keyring 后端不可用(抛出 `NoKeyringError`)且无对应环境变量
|
||||
- **THEN** `get_secure()` 直接读取 `config.json` 中的明文值,并打印 WARNING 日志,不抛出异常
|
||||
|
||||
#### Scenario: 环境变量优先于 keyring
|
||||
- **WHEN** 环境变量 `AUTOBOT_API_KEY` 已设置且 keyring 中也有相同 key 的值
|
||||
- **THEN** `get_secure("api_key")` 返回环境变量的值
|
||||
|
||||
#### Scenario: 通过 UI 设置新的 API Key
|
||||
- **WHEN** 用户在 Gradio UI 中输入新的 API Key 并保存
|
||||
- **THEN** 调用 `set_secure("api_key", value)` 将值存入 keyring(或在降级模式下写入 `config.json`),UI 不显示原始值
|
||||
@ -0,0 +1,23 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 发布完成后清理本次生成的临时图片文件
|
||||
`publish_to_xhs()` 函数 SHALL 在发布流程(无论成功或失败)结束后,删除本次调用写入 `_temp_publish/` 目录的 AI 生成临时图片文件。删除失败 SHALL 仅记录 WARNING 日志,不影响返回结果。
|
||||
|
||||
#### Scenario: 发布成功后临时文件被清理
|
||||
- **WHEN** `publish_to_xhs()` 发布成功并返回成功消息
|
||||
- **THEN** 本次写入的所有 `ai_N.jpg` 临时文件已从磁盘删除
|
||||
|
||||
#### Scenario: 发布失败后临时文件同样被清理
|
||||
- **WHEN** `publish_to_xhs()` 因网络错误等原因抛出异常或返回失败消息
|
||||
- **THEN** 本次写入的所有 `ai_N.jpg` 临时文件已从磁盘删除
|
||||
|
||||
#### Scenario: 清理失败不阻断主流程
|
||||
- **WHEN** 临时文件删除时抛出 `OSError`(如文件已被其他进程占用)
|
||||
- **THEN** 系统记录 WARNING 日志并继续,`publish_to_xhs()` 的返回值不受影响
|
||||
|
||||
### Requirement: 不清理其他会话的临时文件
|
||||
发布清理逻辑 SHALL 只删除本次调用写入的文件(通过追踪写入路径列表),不执行 `_temp_publish/` 目录的全量清空。
|
||||
|
||||
#### Scenario: 并发发布场景下不误删其他文件
|
||||
- **WHEN** 两次发布准备流程同时写入 `_temp_publish/` 目录
|
||||
- **THEN** 每次清理只删除自己写入的文件,不影响另一次的文件
|
||||
@ -0,0 +1,19 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 笔记列表缓存读写受互斥锁保护
|
||||
模块级全局变量 `_cached_proactive_entries` 和 `_cached_my_note_entries` 的所有读写操作 SHALL 在 `threading.RLock` 的保护下执行,以防止 Gradio 回调并发调用时产生数据竞态。
|
||||
|
||||
#### Scenario: 并发刷新时缓存更新不出现竞态
|
||||
- **WHEN** 两个 Gradio 回调线程同时调用 `_fetch_and_cache()`
|
||||
- **THEN** 最终缓存状态为其中一次完整写入的结果,不出现部分更新或列表长度异常
|
||||
|
||||
#### Scenario: 读取缓存时不被并发写入中断
|
||||
- **WHEN** `_pick_from_cache()` 正在迭代缓存列表时,另一线程触发缓存更新
|
||||
- **THEN** 迭代过程不抛出 `RuntimeError: list changed size during iteration`
|
||||
|
||||
### Requirement: 缓存操作封装为受保护的工具函数
|
||||
模块 SHALL 提供 `_set_cache(name, entries)` 和 `_get_cache(name)` 两个内部函数,统一管理缓存读写,不在业务函数中直接赋值全局列表。
|
||||
|
||||
#### Scenario: 缓存写入通过统一接口
|
||||
- **WHEN** 任意函数需要更新笔记缓存
|
||||
- **THEN** 必须调用 `_set_cache(name, entries)` 而非直接赋值 `_cached_*` 变量
|
||||
@ -0,0 +1,19 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 内容创作 Tab 的 UI 代码迁移至独立模块
|
||||
`ui/tab_create.py` SHALL 包含原 `main.py` 中「内容创作 Tab」的全部 Gradio 组件定义和事件绑定,并导出 `build_tab() -> None` 函数,该函数接受一个 `gr.Blocks` 上下文,在其中构建 Tab 内容。`main.py` SHALL 通过 `from ui.tab_create import build_tab` 调用该函数,不在主文件中保留重复的组件代码。
|
||||
|
||||
#### Scenario: main.py 正常启动并显示内容创作 Tab
|
||||
- **WHEN** 运行 `python main.py` 启动 Gradio 应用
|
||||
- **THEN** 内容创作 Tab 正常显示,所有组件与迁移前功能一致
|
||||
|
||||
#### Scenario: tab_create 模块可独立导入
|
||||
- **WHEN** 在 Python 中执行 `from ui.tab_create import build_tab`
|
||||
- **THEN** 不抛出任何导入错误,`build_tab` 为可调用对象
|
||||
|
||||
### Requirement: ui/ 目录结构规范
|
||||
`ui/` 目录 SHALL 包含 `__init__.py`,每个 Tab 模块文件命名约定为 `tab_<name>.py`,不在 Tab 模块中直接调用全局服务初始化代码(如 `ConfigManager()`、`LLMService()` 等单例初始化应由 `main.py` 完成并通过参数或模块级引用传入)。
|
||||
|
||||
#### Scenario: 新增 Tab 模块的标准结构
|
||||
- **WHEN** 开发者创建新的 `ui/tab_*.py` 文件
|
||||
- **THEN** 该文件导出 `build_tab(...)` 函数,且顶层不包含副作用代码(不在 import 时触发服务连接)
|
||||
@ -0,0 +1,55 @@
|
||||
## 1. 依赖与环境准备
|
||||
|
||||
- [x] 1.1 在 `requirements.txt` 中添加 `keyring>=24.0.0`
|
||||
- [x] 1.2 运行 `pip install keyring` 并验证在当前系统(Windows)可正常使用 `keyring.get_password` / `keyring.set_password`
|
||||
|
||||
## 2. 安全配置(secure-config)
|
||||
|
||||
- [x] 2.1 在 `config_manager.py` 中新增 `get_secure(key: str) -> str` 方法:优先读取环境变量 `AUTOBOT_<KEY.upper()>`,其次读取系统 keyring,最后回退 `config.json` 明文(自动迁移一次),捕获 `keyring.errors.NoKeyringError` 并降级
|
||||
- [x] 2.2 在 `config_manager.py` 中新增 `set_secure(key: str, value: str)` 方法:写入系统 keyring(降级模式下写入 `config.json`),并将 `config.json` 对应字段更新为占位符 `"[keyring]"`
|
||||
- [x] 2.3 将 `main.py` 中 LLM 提供商的 `api_key` 读写全部替换为 `cfg.get_secure()` / `cfg.set_secure()`
|
||||
- [ ] 2.4 手动测试:重启应用后 `config.json` 中 `api_key` 已变为 `"[keyring]"`,LLM 连接功能正常
|
||||
|
||||
## 3. JSON 原子写(atomic-persistence)
|
||||
|
||||
- [x] 3.1 在 `config_manager.py` 的 `save()` 方法中将直接 `open(CONFIG_FILE, "w")` 改为 `tempfile.mkstemp(dir=<same_dir>)` + 写入 + `os.replace()` 原子重命名
|
||||
- [x] 3.2 在 `analytics_service.py` 的 `_save_analytics()` 方法中同样改为原子写
|
||||
- [x] 3.3 在 `analytics_service.py` 的 `_save_weights()` 方法中同样改为原子写
|
||||
- [ ] 3.4 测试:在写入过程中(`time.sleep` 模拟)验证目标文件仍完整,临时文件被清理
|
||||
|
||||
## 4. 线程安全缓存(thread-safe-cache)
|
||||
|
||||
- [x] 4.1 在 `main.py` 顶部声明 `_cache_lock = threading.RLock()`
|
||||
- [x] 4.2 新增内部函数 `_set_cache(name: str, entries: list)` 和 `_get_cache(name: str) -> list`,内部使用 `with _cache_lock:` 保护
|
||||
- [x] 4.3 将 `_fetch_and_cache()` 中对 `_cached_proactive_entries` / `_cached_my_note_entries` 的直接赋值改为调用 `_set_cache()`
|
||||
- [x] 4.4 将 `_pick_from_cache()` 中读取缓存改为调用 `_get_cache()`(在锁内完成列表快照拷贝)
|
||||
- [x] 4.5 将 `fetch_my_notes()` 中对 `_cached_my_note_entries` 的直接赋值改为调用 `_set_cache()`
|
||||
|
||||
## 5. 发布前输入校验(publish-input-validation)
|
||||
|
||||
- [x] 5.1 在 `publish_to_xhs()` 函数内、MCP 调用前添加标题长度校验(`len(title) > 20` 返回错误)
|
||||
- [x] 5.2 添加图片数量下限校验(`len(image_paths) == 0` 返回「至少需要 1 张图片」)
|
||||
- [x] 5.3 添加图片数量上限校验(`len(image_paths) > 18` 返回含实际数量的错误消息)
|
||||
- [x] 5.4 添加图片文件存在性校验(遍历 `image_paths`,发现不存在的文件时返回含路径的错误)
|
||||
- [x] 5.5 在 Gradio UI 的发布按钮标题输入框旁添加字符计数提示(`gr.Textbox` 的 `info` 参数)
|
||||
|
||||
## 6. 临时文件生命周期(temp-file-lifecycle)
|
||||
|
||||
- [x] 6.1 在 `publish_to_xhs()` 中记录本次写入的 AI 临时图片路径到局部变量 `ai_temp_files = []`
|
||||
- [x] 6.2 在函数末尾添加 `finally:` 块,遍历 `ai_temp_files` 逐一调用 `os.remove()`,捕获 `OSError` 仅记录 `logger.warning`
|
||||
- [ ] 6.3 验证:发布成功后 `_temp_publish/` 目录中的 `ai_*.jpg` 文件已被删除;发布失败后同样被清理
|
||||
|
||||
## 7. UI 模块拆分(ui-module-split)
|
||||
|
||||
- [x] 7.1 创建 `ui/` 目录,添加 `ui/__init__.py`(空文件)
|
||||
- [x] 7.2 创建 `ui/tab_create.py`,将 `main.py` 中「内容创作 Tab」的所有 Gradio 组件定义和 `.click()` / `.change()` 事件绑定代码迁移至该文件,导出 `build_tab(cfg, mcp_url_box, ...)` 函数
|
||||
- [x] 7.3 在 `main.py` 中用 `from ui.tab_create import build_tab` + 调用替换原有内容创作 Tab 代码
|
||||
- [ ] 7.4 启动应用,验证内容创作 Tab 功能与迁移前完全一致(文案生成、图片生成、发布按钮均正常)
|
||||
|
||||
## 8. 集成验证
|
||||
|
||||
- [ ] 8.1 启动应用,依次测试:LLM 连接 → 文案生成 → 图片生成 → 发布(包含校验不通过和校验通过两个场景)
|
||||
- [ ] 8.2 检查 `config.json` 中无明文 API Key
|
||||
- [ ] 8.3 检查 `_temp_publish/` 目录在发布后为空(或只含本次以外的文件)
|
||||
- [ ] 8.4 检查 `autobot.log` 中无 ERROR 级别日志
|
||||
|
||||
@ -4,10 +4,8 @@ schema: spec-driven
|
||||
# This is shown to AI when creating artifacts.
|
||||
# Add your tech stack, conventions, style guides, domain knowledge, etc.
|
||||
# Example:
|
||||
# context: |
|
||||
# Tech stack: TypeScript, React, Node.js
|
||||
# We use conventional commits
|
||||
# Domain: e-commerce platform
|
||||
context: |
|
||||
使用中文
|
||||
|
||||
# Per-artifact rules (optional)
|
||||
# Add custom rules for specific artifacts.
|
||||
|
||||
16
openspec/specs/atomic-persistence/spec.md
Normal file
16
openspec/specs/atomic-persistence/spec.md
Normal file
@ -0,0 +1,16 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: JSON 文件写入使用原子操作
|
||||
`ConfigManager.save()`、`AnalyticsService._save_analytics()` 和 `AnalyticsService._save_weights()` SHALL 使用「写临时文件 → `os.replace()` 原子重命名」的方式持久化数据。临时文件 SHALL 创建于与目标文件相同的目录(同卷),以确保 `os.replace()` 的原子性。
|
||||
|
||||
#### Scenario: 写入过程中进程中断不产生损坏文件
|
||||
- **WHEN** JSON 写入过程中进程被强制终止
|
||||
- **THEN** 目标文件保持写入前的完整状态,不出现空文件或半写入的 JSON
|
||||
|
||||
#### Scenario: 正常写入成功替换目标文件
|
||||
- **WHEN** `ConfigManager.save()` 被调用且数据合法
|
||||
- **THEN** 目标 `config.json` 被更新为最新内容,写入前存在的临时文件已被清理
|
||||
|
||||
#### Scenario: 临时文件与目标文件在同一目录
|
||||
- **WHEN** 调用任意原子写函数
|
||||
- **THEN** 临时文件的父目录与目标文件的父目录相同(通过 `tempfile.mkstemp(dir=<target_dir>)` 实现)
|
||||
30
openspec/specs/publish-input-validation/spec.md
Normal file
30
openspec/specs/publish-input-validation/spec.md
Normal file
@ -0,0 +1,30 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 发布前校验标题、正文和图片
|
||||
`publish_to_xhs()` 函数 SHALL 在调用 MCP 发布接口前执行以下校验,任何校验失败 SHALL 立即返回包含明确说明的错误消息字符串,不发起网络请求:
|
||||
|
||||
| 字段 | 规则 |
|
||||
|------|------|
|
||||
| 标题 | 非空,长度 ≤ 20 个字符(中英文均按 1 字符计) |
|
||||
| 图片数量 | 至少 1 张,至多 18 张 |
|
||||
| 图片文件 | 每个路径对应的文件在磁盘上真实存在 |
|
||||
|
||||
#### Scenario: 标题超长时返回明确错误
|
||||
- **WHEN** `publish_to_xhs()` 被调用且标题字符数超过 20
|
||||
- **THEN** 返回包含「标题超长」提示及当前字符数的错误字符串,不调用 MCP 接口
|
||||
|
||||
#### Scenario: 无图片时返回明确错误
|
||||
- **WHEN** `publish_to_xhs()` 被调用且最终收集到的图片路径列表为空
|
||||
- **THEN** 返回「至少需要 1 张图片」的错误字符串
|
||||
|
||||
#### Scenario: 图片数量超限时返回明确错误
|
||||
- **WHEN** 最终图片路径列表超过 18 张
|
||||
- **THEN** 返回包含当前图片数和限制数的错误字符串,不发起发布请求
|
||||
|
||||
#### Scenario: 图片文件不存在时返回明确错误
|
||||
- **WHEN** 图片路径列表中有路径对应的文件不存在于磁盘
|
||||
- **THEN** 返回包含该文件路径的「文件不存在」错误字符串
|
||||
|
||||
#### Scenario: 校验通过后正常发布
|
||||
- **WHEN** 所有字段均通过校验
|
||||
- **THEN** 正常调用 MCP 接口发布,行为与改造前一致
|
||||
20
openspec/specs/secure-config/spec.md
Normal file
20
openspec/specs/secure-config/spec.md
Normal file
@ -0,0 +1,20 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 敏感字段通过系统 keyring 或环境变量存储
|
||||
ConfigManager SHALL 提供 `get_secure(key: str) -> str` 和 `set_secure(key: str, value: str)` 接口,用于读写需要保护的配置项(API Key 等)。读取优先级:环境变量 `AUTOBOT_<KEY>` > 系统 keyring > `config.json` 明文(兼容旧版,读取后自动迁移)。`config.json` 中已迁移的字段值替换为占位符字符串 `"[keyring]"`。
|
||||
|
||||
#### Scenario: 首次读取明文 API Key 时自动迁移
|
||||
- **WHEN** 调用 `get_secure("api_key")` 且 `config.json` 中该字段为普通字符串(非占位符)
|
||||
- **THEN** 系统将该值写入 keyring,将 `config.json` 中该字段更新为 `"[keyring]"`,并返回原始值
|
||||
|
||||
#### Scenario: keyring 不可用时降级为明文
|
||||
- **WHEN** 系统 keyring 后端不可用(抛出 `NoKeyringError`)且无对应环境变量
|
||||
- **THEN** `get_secure()` 直接读取 `config.json` 中的明文值,并打印 WARNING 日志,不抛出异常
|
||||
|
||||
#### Scenario: 环境变量优先于 keyring
|
||||
- **WHEN** 环境变量 `AUTOBOT_API_KEY` 已设置且 keyring 中也有相同 key 的值
|
||||
- **THEN** `get_secure("api_key")` 返回环境变量的值
|
||||
|
||||
#### Scenario: 通过 UI 设置新的 API Key
|
||||
- **WHEN** 用户在 Gradio UI 中输入新的 API Key 并保存
|
||||
- **THEN** 调用 `set_secure("api_key", value)` 将值存入 keyring(或在降级模式下写入 `config.json`),UI 不显示原始值
|
||||
23
openspec/specs/temp-file-lifecycle/spec.md
Normal file
23
openspec/specs/temp-file-lifecycle/spec.md
Normal file
@ -0,0 +1,23 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 发布完成后清理本次生成的临时图片文件
|
||||
`publish_to_xhs()` 函数 SHALL 在发布流程(无论成功或失败)结束后,删除本次调用写入 `_temp_publish/` 目录的 AI 生成临时图片文件。删除失败 SHALL 仅记录 WARNING 日志,不影响返回结果。
|
||||
|
||||
#### Scenario: 发布成功后临时文件被清理
|
||||
- **WHEN** `publish_to_xhs()` 发布成功并返回成功消息
|
||||
- **THEN** 本次写入的所有 `ai_N.jpg` 临时文件已从磁盘删除
|
||||
|
||||
#### Scenario: 发布失败后临时文件同样被清理
|
||||
- **WHEN** `publish_to_xhs()` 因网络错误等原因抛出异常或返回失败消息
|
||||
- **THEN** 本次写入的所有 `ai_N.jpg` 临时文件已从磁盘删除
|
||||
|
||||
#### Scenario: 清理失败不阻断主流程
|
||||
- **WHEN** 临时文件删除时抛出 `OSError`(如文件已被其他进程占用)
|
||||
- **THEN** 系统记录 WARNING 日志并继续,`publish_to_xhs()` 的返回值不受影响
|
||||
|
||||
### Requirement: 不清理其他会话的临时文件
|
||||
发布清理逻辑 SHALL 只删除本次调用写入的文件(通过追踪写入路径列表),不执行 `_temp_publish/` 目录的全量清空。
|
||||
|
||||
#### Scenario: 并发发布场景下不误删其他文件
|
||||
- **WHEN** 两次发布准备流程同时写入 `_temp_publish/` 目录
|
||||
- **THEN** 每次清理只删除自己写入的文件,不影响另一次的文件
|
||||
19
openspec/specs/thread-safe-cache/spec.md
Normal file
19
openspec/specs/thread-safe-cache/spec.md
Normal file
@ -0,0 +1,19 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 笔记列表缓存读写受互斥锁保护
|
||||
模块级全局变量 `_cached_proactive_entries` 和 `_cached_my_note_entries` 的所有读写操作 SHALL 在 `threading.RLock` 的保护下执行,以防止 Gradio 回调并发调用时产生数据竞态。
|
||||
|
||||
#### Scenario: 并发刷新时缓存更新不出现竞态
|
||||
- **WHEN** 两个 Gradio 回调线程同时调用 `_fetch_and_cache()`
|
||||
- **THEN** 最终缓存状态为其中一次完整写入的结果,不出现部分更新或列表长度异常
|
||||
|
||||
#### Scenario: 读取缓存时不被并发写入中断
|
||||
- **WHEN** `_pick_from_cache()` 正在迭代缓存列表时,另一线程触发缓存更新
|
||||
- **THEN** 迭代过程不抛出 `RuntimeError: list changed size during iteration`
|
||||
|
||||
### Requirement: 缓存操作封装为受保护的工具函数
|
||||
模块 SHALL 提供 `_set_cache(name, entries)` 和 `_get_cache(name)` 两个内部函数,统一管理缓存读写,不在业务函数中直接赋值全局列表。
|
||||
|
||||
#### Scenario: 缓存写入通过统一接口
|
||||
- **WHEN** 任意函数需要更新笔记缓存
|
||||
- **THEN** 必须调用 `_set_cache(name, entries)` 而非直接赋值 `_cached_*` 变量
|
||||
19
openspec/specs/ui-module-split/spec.md
Normal file
19
openspec/specs/ui-module-split/spec.md
Normal file
@ -0,0 +1,19 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 内容创作 Tab 的 UI 代码迁移至独立模块
|
||||
`ui/tab_create.py` SHALL 包含原 `main.py` 中「内容创作 Tab」的全部 Gradio 组件定义和事件绑定,并导出 `build_tab() -> None` 函数,该函数接受一个 `gr.Blocks` 上下文,在其中构建 Tab 内容。`main.py` SHALL 通过 `from ui.tab_create import build_tab` 调用该函数,不在主文件中保留重复的组件代码。
|
||||
|
||||
#### Scenario: main.py 正常启动并显示内容创作 Tab
|
||||
- **WHEN** 运行 `python main.py` 启动 Gradio 应用
|
||||
- **THEN** 内容创作 Tab 正常显示,所有组件与迁移前功能一致
|
||||
|
||||
#### Scenario: tab_create 模块可独立导入
|
||||
- **WHEN** 在 Python 中执行 `from ui.tab_create import build_tab`
|
||||
- **THEN** 不抛出任何导入错误,`build_tab` 为可调用对象
|
||||
|
||||
### Requirement: ui/ 目录结构规范
|
||||
`ui/` 目录 SHALL 包含 `__init__.py`,每个 Tab 模块文件命名约定为 `tab_<name>.py`,不在 Tab 模块中直接调用全局服务初始化代码(如 `ConfigManager()`、`LLMService()` 等单例初始化应由 `main.py` 完成并通过参数或模块级引用传入)。
|
||||
|
||||
#### Scenario: 新增 Tab 模块的标准结构
|
||||
- **WHEN** 开发者创建新的 `ui/tab_*.py` 文件
|
||||
- **THEN** 该文件导出 `build_tab(...)` 函数,且顶层不包含副作用代码(不在 import 时触发服务连接)
|
||||
@ -2,3 +2,4 @@ gradio>=4.0.0
|
||||
requests>=2.28.0
|
||||
Pillow>=9.0.0
|
||||
matplotlib>=3.5.0
|
||||
keyring>=24.0.0
|
||||
|
||||
1
ui/__init__.py
Normal file
1
ui/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# UI 模块包
|
||||
172
ui/tab_create.py
Normal file
172
ui/tab_create.py
Normal file
@ -0,0 +1,172 @@
|
||||
"""
|
||||
内容创作 Tab UI 模块
|
||||
包含 Tab 1「✨ 内容创作」的所有 Gradio 组件定义和事件绑定
|
||||
"""
|
||||
import gradio as gr
|
||||
|
||||
|
||||
def build_tab(
|
||||
config: dict,
|
||||
styles: list,
|
||||
sd_preset_names: list,
|
||||
default_negative: str,
|
||||
# 共享的 Gradio 组件(由 main.py 创建并传入)
|
||||
llm_model,
|
||||
sd_model,
|
||||
sd_url,
|
||||
persona,
|
||||
status_bar,
|
||||
face_swap_toggle,
|
||||
face_image_preview,
|
||||
mcp_url,
|
||||
# 业务处理函数(由 main.py 传入,避免循环导入)
|
||||
fn_gen_copy,
|
||||
fn_gen_img,
|
||||
fn_export,
|
||||
fn_publish,
|
||||
fn_get_sd_preset,
|
||||
fn_cfg_set,
|
||||
fn_cfg_update,
|
||||
):
|
||||
"""
|
||||
构建「✨ 内容创作」Tab,注册所有事件绑定。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
config : 当前配置字典(用于读取初始值)
|
||||
styles : 文案风格列表
|
||||
sd_preset_names : SD 生成模式名称列表
|
||||
default_negative: 默认反向提示词
|
||||
llm_model / sd_model / ... : 由全局设置栏创建的共享组件引用
|
||||
fn_* : 业务逻辑回调函数
|
||||
|
||||
Returns
|
||||
-------
|
||||
None —— 所有事件绑定均在此函数内完成,无需外部再次绑定。
|
||||
"""
|
||||
with gr.Tab("✨ 内容创作"):
|
||||
# 本 Tab 私有的图片状态(不被其他 Tab 使用)
|
||||
state_images = gr.State([])
|
||||
|
||||
with gr.Row():
|
||||
# ---- 左栏:输入 ----
|
||||
with gr.Column(scale=1):
|
||||
gr.Markdown("### 💡 构思")
|
||||
topic = gr.Textbox(label="笔记主题", placeholder="例如:优衣库早春穿搭")
|
||||
style = gr.Dropdown(
|
||||
styles,
|
||||
label="风格", value="好物种草",
|
||||
)
|
||||
btn_gen_copy = gr.Button("✨ 第一步:生成文案", variant="primary")
|
||||
|
||||
gr.Markdown("---")
|
||||
gr.Markdown("### 🎨 绘图参数")
|
||||
quality_mode = gr.Radio(
|
||||
sd_preset_names,
|
||||
label="生成模式",
|
||||
value=config.get("quality_mode", "标准 (约1分钟)"),
|
||||
info="快速≈30s 标准≈1min 精细≈2-3min (SDXL)",
|
||||
)
|
||||
with gr.Accordion("高级设置 (覆盖预设)", open=False):
|
||||
neg_prompt = gr.Textbox(
|
||||
label="反向提示词",
|
||||
value=config.get("sd_negative_prompt", default_negative),
|
||||
lines=2,
|
||||
)
|
||||
steps = gr.Slider(8, 50, value=config.get("sd_steps", 20), step=1, label="步数")
|
||||
cfg_scale = gr.Slider(1, 15, value=config.get("sd_cfg_scale", 5.5), step=0.5, label="CFG Scale")
|
||||
btn_gen_img = gr.Button("🎨 第二步:生成图片", variant="primary")
|
||||
|
||||
# ---- 中栏:文案编辑 ----
|
||||
with gr.Column(scale=1):
|
||||
gr.Markdown("### 📝 文案编辑")
|
||||
res_title = gr.Textbox(
|
||||
label="标题",
|
||||
interactive=True,
|
||||
info="小红书限制 ≤20 字,超出将无法发布",
|
||||
)
|
||||
res_content = gr.TextArea(
|
||||
label="正文 (可手动修改)", lines=12, interactive=True,
|
||||
)
|
||||
res_prompt = gr.TextArea(
|
||||
label="绘图提示词", lines=3, interactive=True,
|
||||
)
|
||||
res_tags = gr.Textbox(
|
||||
label="话题标签 (逗号分隔)", interactive=True,
|
||||
placeholder="穿搭, 春季, 好物种草",
|
||||
)
|
||||
|
||||
# ---- 右栏:预览 & 发布 ----
|
||||
with gr.Column(scale=1):
|
||||
gr.Markdown("### 🖼️ 视觉预览")
|
||||
gallery = gr.Gallery(label="AI 生成图片", columns=2, height=300)
|
||||
local_images = gr.File(
|
||||
label="📁 上传本地图片(可混排)",
|
||||
file_count="multiple",
|
||||
file_types=["image"],
|
||||
)
|
||||
|
||||
gr.Markdown("### 🚀 发布")
|
||||
schedule_time = gr.Textbox(
|
||||
label="定时发布 (可选, ISO8601格式)",
|
||||
placeholder="如 2026-02-08T18:00:00+08:00,留空=立即发布",
|
||||
)
|
||||
with gr.Row():
|
||||
btn_export = gr.Button("📂 导出本地", variant="secondary")
|
||||
btn_publish = gr.Button("🚀 发布到小红书", variant="primary")
|
||||
publish_msg = gr.Markdown("")
|
||||
|
||||
# ---- 事件绑定 ----
|
||||
|
||||
btn_gen_copy.click(
|
||||
fn=fn_gen_copy,
|
||||
inputs=[llm_model, topic, style, sd_model, persona],
|
||||
outputs=[res_title, res_content, res_prompt, res_tags, status_bar],
|
||||
)
|
||||
|
||||
def save_quality_mode(mode):
|
||||
fn_cfg_set("quality_mode", mode)
|
||||
p = fn_get_sd_preset(mode)
|
||||
return p["steps"], p["cfg_scale"]
|
||||
|
||||
quality_mode.change(
|
||||
fn=save_quality_mode,
|
||||
inputs=[quality_mode],
|
||||
outputs=[steps, cfg_scale],
|
||||
)
|
||||
|
||||
steps.change(fn=lambda s: fn_cfg_set("sd_steps", s), inputs=[steps], outputs=[])
|
||||
cfg_scale.change(fn=lambda c: fn_cfg_set("sd_cfg_scale", c), inputs=[cfg_scale], outputs=[])
|
||||
neg_prompt.change(fn=lambda n: fn_cfg_set("sd_negative_prompt", n), inputs=[neg_prompt], outputs=[])
|
||||
|
||||
btn_gen_img.click(
|
||||
fn=fn_gen_img,
|
||||
inputs=[sd_url, res_prompt, neg_prompt, sd_model, steps, cfg_scale,
|
||||
face_swap_toggle, face_image_preview, quality_mode, persona],
|
||||
outputs=[gallery, state_images, status_bar],
|
||||
)
|
||||
|
||||
btn_export.click(
|
||||
fn=fn_export,
|
||||
inputs=[res_title, res_content, state_images],
|
||||
outputs=[publish_msg],
|
||||
)
|
||||
|
||||
btn_publish.click(
|
||||
fn=fn_publish,
|
||||
inputs=[res_title, res_content, res_tags, state_images,
|
||||
local_images, mcp_url, schedule_time],
|
||||
outputs=[publish_msg],
|
||||
)
|
||||
|
||||
# 返回可能被其他 Tab 引用的组件
|
||||
return {
|
||||
"res_title": res_title,
|
||||
"res_content": res_content,
|
||||
"res_prompt": res_prompt,
|
||||
"res_tags": res_tags,
|
||||
"quality_mode": quality_mode,
|
||||
"steps": steps,
|
||||
"cfg_scale": cfg_scale,
|
||||
"neg_prompt": neg_prompt,
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user