✨ feat(llm): 增强 LLM 服务的健壮性与容错能力
- 新增模型降级机制,当主模型失败时自动尝试备选模型列表【FALLBACK_MODELS】 - 增强 `_chat` 方法,支持空返回检测、json_mode 回退和多重错误处理 - 重构 `_parse_json` 方法,实现五重容错解析策略以应对不同模型的输出格式 - 为 `generate_copy`、`generate_copy_with_reference` 和 `analyze_hotspots` 方法添加重试逻辑,在 JSON 解析失败时自动关闭 json_mode 重试 🔧 chore(config): 更新默认模型配置与安全令牌 - 将默认 LLM 模型从 `gemini-3-flash-preview` 更改为 `deepseek-v3` - 更新 `xsec_token` 安全令牌 ✨ feat(sd): 集成 ReActor 换脸功能并扩展人设主题池 - 在 `SDService` 中新增头像管理静态方法 (`load_face_image`, `save_face_image`) 和 ReActor 参数构建方法 - 为 `txt2img` 方法添加 `face_image` 参数,支持在生成图片时自动换脸 - 在 `main.py` 的 Web UI 中新增头像上传、预览与管理界面 - 扩展 `generate_images` 函数,支持根据复选框状态启用换脸功能 - 重构人设系统,为 24 种预设人设分别定义专属的【主题池】和【评论关键词池】,并实现人设切换时的自动联动更新 - 在自动化发布 (`auto_publish_once`) 和定时调度 (`_scheduler_loop`) 中集成换脸选项 📝 docs(main): 添加新图片资源 - 新增图片资源文件:`beauty.png`, `my_face.png`, `myself.jpg`, `zjz.png`
This commit is contained in:
parent
500e47ebcb
commit
358b957f5d
BIN
beauty.png
Normal file
BIN
beauty.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.5 MiB |
@ -3,7 +3,7 @@
|
|||||||
"base_url": "https://wolfai.top/v1",
|
"base_url": "https://wolfai.top/v1",
|
||||||
"sd_url": "http://127.0.0.1:7861",
|
"sd_url": "http://127.0.0.1:7861",
|
||||||
"mcp_url": "http://localhost:18060/mcp",
|
"mcp_url": "http://localhost:18060/mcp",
|
||||||
"model": "gemini-3-flash-preview",
|
"model": "deepseek-v3",
|
||||||
"persona": "温柔知性的时尚博主",
|
"persona": "温柔知性的时尚博主",
|
||||||
"auto_reply_enabled": false,
|
"auto_reply_enabled": false,
|
||||||
"schedule_enabled": false,
|
"schedule_enabled": false,
|
||||||
@ -21,5 +21,5 @@
|
|||||||
"base_url": "https://wolfai.top/v1"
|
"base_url": "https://wolfai.top/v1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"xsec_token": "ABdAEbqP9ScgelmyolJxsnpCr_e645SCpnub2dLZJc4Ck="
|
"xsec_token": "ABfkw0sdbz9Lf-js1d83biryHO6o13nCCPwPbVK6eGYR8="
|
||||||
}
|
}
|
||||||
191
llm_service.py
191
llm_service.py
@ -224,6 +224,9 @@ PROMPT_COPY_WITH_REFERENCE = """
|
|||||||
class LLMService:
|
class LLMService:
|
||||||
"""LLM API 服务封装"""
|
"""LLM API 服务封装"""
|
||||||
|
|
||||||
|
# 当主模型返回空内容时,依次尝试的备选模型列表
|
||||||
|
FALLBACK_MODELS = ["deepseek-v3", "gemini-2.5-flash", "deepseek-v3.1"]
|
||||||
|
|
||||||
def __init__(self, api_key: str, base_url: str, model: str = "gpt-3.5-turbo"):
|
def __init__(self, api_key: str, base_url: str, model: str = "gpt-3.5-turbo"):
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
@ -231,7 +234,7 @@ class LLMService:
|
|||||||
|
|
||||||
def _chat(self, system_prompt: str, user_message: str,
|
def _chat(self, system_prompt: str, user_message: str,
|
||||||
json_mode: bool = True, temperature: float = 0.8) -> str:
|
json_mode: bool = True, temperature: float = 0.8) -> str:
|
||||||
"""底层聊天接口"""
|
"""底层聊天接口(含空返回检测、json_mode 回退、模型降级)"""
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@ -239,8 +242,13 @@ class LLMService:
|
|||||||
if json_mode:
|
if json_mode:
|
||||||
user_message = user_message + "\n请以json格式返回。"
|
user_message = user_message + "\n请以json格式返回。"
|
||||||
|
|
||||||
|
# 构建要尝试的模型列表:主模型 + 备选模型(去重)
|
||||||
|
models_to_try = [self.model] + [m for m in self.FALLBACK_MODELS if m != self.model]
|
||||||
|
|
||||||
|
last_error = None
|
||||||
|
for model_idx, current_model in enumerate(models_to_try):
|
||||||
payload = {
|
payload = {
|
||||||
"model": self.model,
|
"model": current_model,
|
||||||
"messages": [
|
"messages": [
|
||||||
{"role": "system", "content": system_prompt},
|
{"role": "system", "content": system_prompt},
|
||||||
{"role": "user", "content": user_message},
|
{"role": "user", "content": user_message},
|
||||||
@ -257,18 +265,126 @@ class LLMService:
|
|||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
content = resp.json()["choices"][0]["message"]["content"]
|
content = resp.json()["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
# 检测空返回 — 如果启用了 json_mode 且返回为空,回退去掉 response_format 重试
|
||||||
|
if not content or not content.strip():
|
||||||
|
if json_mode:
|
||||||
|
logger.warning("[%s] LLM 返回空内容 (json_mode=True),关闭 json_mode 回退重试...", current_model)
|
||||||
|
payload.pop("response_format", None)
|
||||||
|
resp2 = requests.post(
|
||||||
|
f"{self.base_url}/chat/completions",
|
||||||
|
headers=headers, json=payload, timeout=90
|
||||||
|
)
|
||||||
|
resp2.raise_for_status()
|
||||||
|
content = resp2.json()["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
if not content or not content.strip():
|
||||||
|
# 当前模型完全无法返回内容,尝试下一个模型
|
||||||
|
if model_idx < len(models_to_try) - 1:
|
||||||
|
next_model = models_to_try[model_idx + 1]
|
||||||
|
logger.warning("[%s] 返回空内容,自动降级到模型: %s", current_model, next_model)
|
||||||
|
continue
|
||||||
|
raise RuntimeError(f"所有模型均返回空内容(已尝试: {', '.join(models_to_try[:model_idx+1])})")
|
||||||
|
|
||||||
|
if model_idx > 0:
|
||||||
|
logger.info("模型降级成功: %s → %s", self.model, current_model)
|
||||||
return content
|
return content
|
||||||
except requests.exceptions.Timeout:
|
|
||||||
raise TimeoutError("LLM 请求超时,请检查网络或换一个模型")
|
|
||||||
except requests.exceptions.HTTPError as e:
|
except requests.exceptions.HTTPError as e:
|
||||||
raise ConnectionError(f"LLM API 错误 ({resp.status_code}): {resp.text[:200]}")
|
status = getattr(resp, 'status_code', 0)
|
||||||
|
body = getattr(resp, 'text', '')[:300]
|
||||||
|
# 某些模型/提供商不支持 response_format,自动回退重试
|
||||||
|
if json_mode and status in (400, 422, 500):
|
||||||
|
logger.warning("[%s] json_mode 请求失败 (HTTP %s),关闭 response_format 回退重试...", current_model, status)
|
||||||
|
payload.pop("response_format", None)
|
||||||
|
try:
|
||||||
|
resp2 = requests.post(
|
||||||
|
f"{self.base_url}/chat/completions",
|
||||||
|
headers=headers, json=payload, timeout=90
|
||||||
|
)
|
||||||
|
resp2.raise_for_status()
|
||||||
|
content = resp2.json()["choices"][0]["message"]["content"]
|
||||||
|
if content and content.strip():
|
||||||
|
if model_idx > 0:
|
||||||
|
logger.info("模型降级成功: %s → %s", self.model, current_model)
|
||||||
|
return content
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# 当前模型失败,尝试下一个
|
||||||
|
last_error = ConnectionError(f"LLM API 错误 ({status}): {body}")
|
||||||
|
if model_idx < len(models_to_try) - 1:
|
||||||
|
logger.warning("[%s] HTTP %s 失败,降级到: %s", current_model, status, models_to_try[model_idx + 1])
|
||||||
|
continue
|
||||||
|
raise last_error
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
last_error = TimeoutError(f"[{current_model}] LLM 请求超时")
|
||||||
|
if model_idx < len(models_to_try) - 1:
|
||||||
|
logger.warning("[%s] 请求超时,降级到: %s", current_model, models_to_try[model_idx + 1])
|
||||||
|
continue
|
||||||
|
raise TimeoutError("LLM 请求超时,所有模型均超时,请检查网络")
|
||||||
|
|
||||||
|
except (ConnectionError, RuntimeError):
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise RuntimeError(f"LLM 调用异常: {e}")
|
last_error = RuntimeError(f"LLM 调用异常: {e}")
|
||||||
|
if model_idx < len(models_to_try) - 1:
|
||||||
|
logger.warning("[%s] 调用异常 (%s),降级到: %s", current_model, e, models_to_try[model_idx + 1])
|
||||||
|
continue
|
||||||
|
raise last_error
|
||||||
|
|
||||||
|
raise last_error or RuntimeError("LLM 调用失败: 未知错误")
|
||||||
|
|
||||||
def _parse_json(self, text: str) -> dict:
|
def _parse_json(self, text: str) -> dict:
|
||||||
"""从 LLM 返回文本中解析 JSON"""
|
"""从 LLM 返回文本中解析 JSON(多重容错)"""
|
||||||
cleaned = re.sub(r"```json\s*|```", "", text).strip()
|
if not text or not text.strip():
|
||||||
|
raise ValueError("LLM 返回内容为空,无法解析 JSON")
|
||||||
|
|
||||||
|
raw = text.strip()
|
||||||
|
|
||||||
|
# 策略1: 去除 markdown 代码块
|
||||||
|
cleaned = re.sub(r"```(?:json)?\s*", "", raw)
|
||||||
|
cleaned = re.sub(r"```", "", cleaned).strip()
|
||||||
|
|
||||||
|
# 策略2: 直接解析
|
||||||
|
try:
|
||||||
return json.loads(cleaned)
|
return json.loads(cleaned)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 策略3: 提取最外层的 { ... } 块
|
||||||
|
match = re.search(r'(\{[\s\S]*\})', cleaned)
|
||||||
|
if match:
|
||||||
|
try:
|
||||||
|
return json.loads(match.group(1))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 策略4: 逐行查找 JSON 开始位置
|
||||||
|
for i, ch in enumerate(cleaned):
|
||||||
|
if ch == '{':
|
||||||
|
try:
|
||||||
|
return json.loads(cleaned[i:])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
|
||||||
|
# 策略5: 尝试修复常见问题(尾部多余逗号、缺少闭合括号)
|
||||||
|
try:
|
||||||
|
# 去除尾部多余逗号
|
||||||
|
fixed = re.sub(r',\s*([}\]])', r'\1', cleaned)
|
||||||
|
return json.loads(fixed)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 全部失败,打日志并抛出有用的错误信息
|
||||||
|
preview = raw[:500] if len(raw) > 500 else raw
|
||||||
|
logger.error("JSON 解析全部失败,LLM 原始返回: %s", preview)
|
||||||
|
raise ValueError(
|
||||||
|
f"LLM 返回内容无法解析为 JSON。\n"
|
||||||
|
f"返回内容前200字: {raw[:200]}\n\n"
|
||||||
|
f"💡 可能原因: 模型不支持 JSON 输出格式,建议更换模型重试"
|
||||||
|
)
|
||||||
|
|
||||||
# ---------- 业务方法 ----------
|
# ---------- 业务方法 ----------
|
||||||
|
|
||||||
@ -290,10 +406,16 @@ class LLMService:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
def generate_copy(self, topic: str, style: str) -> dict:
|
def generate_copy(self, topic: str, style: str) -> dict:
|
||||||
"""生成小红书文案"""
|
"""生成小红书文案(含重试逻辑)"""
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(2):
|
||||||
|
try:
|
||||||
|
# 第二次尝试不使用 json_mode(兼容不支持的模型)
|
||||||
|
use_json_mode = (attempt == 0)
|
||||||
content = self._chat(
|
content = self._chat(
|
||||||
PROMPT_COPYWRITING,
|
PROMPT_COPYWRITING,
|
||||||
f"主题:{topic}\n风格:{style}",
|
f"主题:{topic}\n风格:{style}",
|
||||||
|
json_mode=use_json_mode,
|
||||||
temperature=0.92,
|
temperature=0.92,
|
||||||
)
|
)
|
||||||
data = self._parse_json(content)
|
data = self._parse_json(content)
|
||||||
@ -310,31 +432,70 @@ class LLMService:
|
|||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
last_error = e
|
||||||
|
if attempt == 0:
|
||||||
|
logger.warning("文案生成 JSON 解析失败 (尝试 %d/2): %s,将关闭 json_mode 重试", attempt + 1, e)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
logger.error("文案生成 JSON 解析失败 (尝试 %d/2): %s", attempt + 1, e)
|
||||||
|
|
||||||
|
raise RuntimeError(f"文案生成失败: LLM 返回无法解析为 JSON,已重试 2 次。\n最后错误: {last_error}")
|
||||||
|
|
||||||
def generate_copy_with_reference(self, topic: str, style: str,
|
def generate_copy_with_reference(self, topic: str, style: str,
|
||||||
reference_notes: str) -> dict:
|
reference_notes: str) -> dict:
|
||||||
"""参考热门笔记生成文案"""
|
"""参考热门笔记生成文案(含重试逻辑)"""
|
||||||
prompt = PROMPT_COPY_WITH_REFERENCE.format(
|
prompt = PROMPT_COPY_WITH_REFERENCE.format(
|
||||||
reference_notes=reference_notes, topic=topic, style=style
|
reference_notes=reference_notes, topic=topic, style=style
|
||||||
)
|
)
|
||||||
content = self._chat(prompt, f"请创作关于「{topic}」的小红书笔记",
|
last_error = None
|
||||||
temperature=0.92)
|
for attempt in range(2):
|
||||||
|
try:
|
||||||
|
use_json_mode = (attempt == 0)
|
||||||
|
content = self._chat(
|
||||||
|
prompt, f"请创作关于「{topic}」的小红书笔记",
|
||||||
|
json_mode=use_json_mode, temperature=0.92,
|
||||||
|
)
|
||||||
data = self._parse_json(content)
|
data = self._parse_json(content)
|
||||||
|
|
||||||
title = data.get("title", "")
|
title = data.get("title", "")
|
||||||
if len(title) > 20:
|
if len(title) > 20:
|
||||||
data["title"] = title[:20]
|
data["title"] = title[:20]
|
||||||
|
|
||||||
# 去 AI 化后处理
|
|
||||||
if "content" in data:
|
if "content" in data:
|
||||||
data["content"] = self._humanize_content(data["content"])
|
data["content"] = self._humanize_content(data["content"])
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
last_error = e
|
||||||
|
if attempt == 0:
|
||||||
|
logger.warning("参考文案生成 JSON 解析失败 (尝试 %d/2): %s,将关闭 json_mode 重试", attempt + 1, e)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
logger.error("参考文案生成 JSON 解析失败 (尝试 %d/2): %s", attempt + 1, e)
|
||||||
|
|
||||||
|
raise RuntimeError(f"参考文案生成失败: LLM 返回无法解析为 JSON,已重试 2 次。\n最后错误: {last_error}")
|
||||||
|
|
||||||
def analyze_hotspots(self, feed_data: str) -> dict:
|
def analyze_hotspots(self, feed_data: str) -> dict:
|
||||||
"""分析热门内容趋势"""
|
"""分析热门内容趋势(含重试逻辑)"""
|
||||||
prompt = PROMPT_HOTSPOT_ANALYSIS.format(feed_data=feed_data)
|
prompt = PROMPT_HOTSPOT_ANALYSIS.format(feed_data=feed_data)
|
||||||
content = self._chat(prompt, "请分析以上热门笔记数据")
|
last_error = None
|
||||||
|
for attempt in range(2):
|
||||||
|
try:
|
||||||
|
use_json_mode = (attempt == 0)
|
||||||
|
content = self._chat(prompt, "请分析以上热门笔记数据",
|
||||||
|
json_mode=use_json_mode)
|
||||||
return self._parse_json(content)
|
return self._parse_json(content)
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
last_error = e
|
||||||
|
if attempt == 0:
|
||||||
|
logger.warning("热点分析 JSON 解析失败 (尝试 %d/2): %s,将关闭 json_mode 重试", attempt + 1, e)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
logger.error("热点分析 JSON 解析失败 (尝试 %d/2): %s", attempt + 1, e)
|
||||||
|
|
||||||
|
raise RuntimeError(f"热点分析失败: LLM 返回无法解析为 JSON,已重试 2 次。\n最后错误: {last_error}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _humanize_content(text: str) -> str:
|
def _humanize_content(text: str) -> str:
|
||||||
|
|||||||
501
main.py
501
main.py
@ -19,7 +19,7 @@ import matplotlib.pyplot as plt
|
|||||||
|
|
||||||
from config_manager import ConfigManager, OUTPUT_DIR
|
from config_manager import ConfigManager, OUTPUT_DIR
|
||||||
from llm_service import LLMService
|
from llm_service import LLMService
|
||||||
from sd_service import SDService, DEFAULT_NEGATIVE
|
from sd_service import SDService, DEFAULT_NEGATIVE, FACE_IMAGE_PATH
|
||||||
from mcp_client import MCPClient, get_mcp_client
|
from mcp_client import MCPClient, get_mcp_client
|
||||||
|
|
||||||
# ================= matplotlib 中文字体配置 =================
|
# ================= matplotlib 中文字体配置 =================
|
||||||
@ -262,6 +262,31 @@ def save_my_user_id(user_id_input):
|
|||||||
return f"✅ 用户 ID 已保存: `{uid}`"
|
return f"✅ 用户 ID 已保存: `{uid}`"
|
||||||
|
|
||||||
|
|
||||||
|
# ================= 头像/换脸管理 =================
|
||||||
|
|
||||||
|
def upload_face_image(img):
|
||||||
|
"""上传并保存头像图片"""
|
||||||
|
if img is None:
|
||||||
|
return None, "❌ 请上传头像图片"
|
||||||
|
try:
|
||||||
|
if isinstance(img, str) and os.path.isfile(img):
|
||||||
|
img = Image.open(img).convert("RGB")
|
||||||
|
elif not isinstance(img, Image.Image):
|
||||||
|
return None, "❌ 无法识别图片格式"
|
||||||
|
path = SDService.save_face_image(img)
|
||||||
|
return img, f"✅ 头像已保存至 {os.path.basename(path)}"
|
||||||
|
except Exception as e:
|
||||||
|
return None, f"❌ 保存失败: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def load_saved_face_image():
|
||||||
|
"""加载已保存的头像"""
|
||||||
|
img = SDService.load_face_image()
|
||||||
|
if img:
|
||||||
|
return img, "✅ 已加载保存的头像"
|
||||||
|
return None, "ℹ️ 尚未设置头像"
|
||||||
|
|
||||||
|
|
||||||
def generate_copy(model, topic, style):
|
def generate_copy(model, topic, style):
|
||||||
"""生成文案"""
|
"""生成文案"""
|
||||||
api_key, base_url, _ = _get_llm_config()
|
api_key, base_url, _ = _get_llm_config()
|
||||||
@ -284,20 +309,33 @@ def generate_copy(model, topic, style):
|
|||||||
return "", "", "", "", f"❌ 生成失败: {e}"
|
return "", "", "", "", f"❌ 生成失败: {e}"
|
||||||
|
|
||||||
|
|
||||||
def generate_images(sd_url, prompt, neg_prompt, model, steps, cfg_scale):
|
def generate_images(sd_url, prompt, neg_prompt, model, steps, cfg_scale, face_swap_on, face_img):
|
||||||
"""生成图片"""
|
"""生成图片(可选 ReActor 换脸)"""
|
||||||
if not model:
|
if not model:
|
||||||
return None, [], "❌ 未选择 SD 模型"
|
return None, [], "❌ 未选择 SD 模型"
|
||||||
try:
|
try:
|
||||||
svc = SDService(sd_url)
|
svc = SDService(sd_url)
|
||||||
|
# 判断是否启用换脸
|
||||||
|
face_image = None
|
||||||
|
if face_swap_on and face_img is not None:
|
||||||
|
if isinstance(face_img, Image.Image):
|
||||||
|
face_image = face_img
|
||||||
|
elif isinstance(face_img, str) and os.path.isfile(face_img):
|
||||||
|
face_image = Image.open(face_img).convert("RGB")
|
||||||
|
if face_swap_on and face_image is None:
|
||||||
|
# 尝试从默认路径加载
|
||||||
|
face_image = SDService.load_face_image()
|
||||||
|
|
||||||
images = svc.txt2img(
|
images = svc.txt2img(
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
negative_prompt=neg_prompt,
|
negative_prompt=neg_prompt,
|
||||||
model=model,
|
model=model,
|
||||||
steps=int(steps),
|
steps=int(steps),
|
||||||
cfg_scale=float(cfg_scale),
|
cfg_scale=float(cfg_scale),
|
||||||
|
face_image=face_image,
|
||||||
)
|
)
|
||||||
return images, images, f"✅ 生成 {len(images)} 张图片"
|
swap_hint = " (已换脸)" if face_image else ""
|
||||||
|
return images, images, f"✅ 生成 {len(images)} 张图片{swap_hint}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("图片生成失败: %s", e)
|
logger.error("图片生成失败: %s", e)
|
||||||
return None, [], f"❌ 绘图失败: {e}"
|
return None, [], f"❌ 绘图失败: {e}"
|
||||||
@ -1025,7 +1063,289 @@ DEFAULT_PERSONAS = [
|
|||||||
|
|
||||||
RANDOM_PERSONA_LABEL = "🎲 随机人设(每次自动切换)"
|
RANDOM_PERSONA_LABEL = "🎲 随机人设(每次自动切换)"
|
||||||
|
|
||||||
# ================= 主题池 =================
|
# ================= 人设 → 分类关键词/主题池映射 =================
|
||||||
|
# 每个人设对应一组相符的评论关键词和主题,切换人设时自动同步
|
||||||
|
|
||||||
|
PERSONA_POOL_MAP = {
|
||||||
|
# ---- 时尚穿搭类 ----
|
||||||
|
"温柔知性的时尚博主": {
|
||||||
|
"topics": [
|
||||||
|
"春季穿搭", "通勤穿搭", "约会穿搭", "显瘦穿搭", "法式穿搭",
|
||||||
|
"极简穿搭", "氛围感穿搭", "一衣多穿", "秋冬叠穿", "夏日清凉穿搭",
|
||||||
|
"生活美学", "衣橱整理", "配色技巧", "基础款穿搭", "轻熟风穿搭",
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"穿搭", "ootd", "早春穿搭", "通勤穿搭", "显瘦", "法式穿搭",
|
||||||
|
"极简风", "氛围感", "轻熟风", "高级感穿搭", "配色",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"元气满满的大学生": {
|
||||||
|
"topics": [
|
||||||
|
"学生党穿搭", "宿舍美食", "平价好物", "校园生活", "学生党护肤",
|
||||||
|
"期末复习", "社团活动", "寝室改造", "奶茶测评", "拍照打卡地",
|
||||||
|
"一人食食谱", "考研经验", "实习经验", "省钱攻略",
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"学生党", "平价好物", "宿舍", "校园", "奶茶", "探店",
|
||||||
|
"拍照", "省钱", "大学生活", "期末", "开学", "室友",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"30岁都市白领丽人": {
|
||||||
|
"topics": [
|
||||||
|
"通勤穿搭", "职场干货", "面试技巧", "简历优化", "时间管理",
|
||||||
|
"理财入门", "轻熟风穿搭", "职场妆容", "咖啡探店", "高效工作法",
|
||||||
|
"副业分享", "自律生活", "下班后充电", "职场人际关系",
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"通勤穿搭", "职场", "面试", "理财", "自律", "高效",
|
||||||
|
"咖啡", "轻熟", "白领", "上班族", "时间管理", "副业",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"精致妈妈": {
|
||||||
|
"topics": [
|
||||||
|
"育儿经验", "家居收纳", "辅食制作", "亲子游", "母婴好物",
|
||||||
|
"宝宝穿搭", "早教启蒙", "产后恢复", "家常菜做法", "小户型收纳",
|
||||||
|
"家庭教育", "孕期护理", "宝宝辅食", "妈妈穿搭",
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"育儿", "收纳", "辅食", "母婴", "亲子", "早教",
|
||||||
|
"宝宝", "家居", "待产", "产后", "妈妈", "家常菜",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"文艺青年摄影师": {
|
||||||
|
"topics": [
|
||||||
|
"旅行攻略", "小众旅行地", "拍照打卡地", "城市citywalk", "古镇旅行",
|
||||||
|
"手机摄影技巧", "胶片摄影", "人像摄影", "风光摄影", "街拍",
|
||||||
|
"咖啡探店", "文艺书店", "展览打卡", "独立书店",
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"旅行", "摄影", "打卡", "citywalk", "胶片", "拍照",
|
||||||
|
"小众", "展览", "文艺", "街拍", "风光", "人像",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"健身达人营养师": {
|
||||||
|
"topics": [
|
||||||
|
"减脂餐分享", "居家健身", "帕梅拉跟练", "跑步入门", "体态矫正",
|
||||||
|
"增肌餐", "蛋白质补充", "运动穿搭", "健身房攻略", "马甲线养成",
|
||||||
|
"热量计算", "健康早餐", "运动恢复", "减脂食谱",
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"减脂", "健身", "减脂餐", "蛋白质", "体态", "马甲线",
|
||||||
|
"帕梅拉", "跑步", "热量", "增肌", "运动", "健康餐",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"资深美妆博主": {
|
||||||
|
"topics": [
|
||||||
|
"妆容教程", "眼妆教程", "唇妆合集", "底妆测评", "护肤心得",
|
||||||
|
"防晒测评", "学生党平价护肤", "敏感肌护肤", "美白攻略",
|
||||||
|
"成分党护肤", "换季护肤", "早C晚A护肤", "抗老护肤",
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"护肤", "化妆教程", "眼影", "口红", "底妆", "防晒",
|
||||||
|
"美白", "敏感肌", "成分", "平价", "测评", "粉底",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"独居女孩": {
|
||||||
|
"topics": [
|
||||||
|
"独居生活", "租房改造", "氛围感房间", "一人食食谱", "好物分享",
|
||||||
|
"香薰推荐", "居家好物", "断舍离", "仪式感生活", "独居安全",
|
||||||
|
"解压方式", "emo急救指南", "桌面布置", "小户型装修",
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"独居", "租房改造", "好物", "氛围感", "一人食", "仪式感",
|
||||||
|
"解压", "居家", "香薰", "ins风", "房间", "断舍离",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"甜品烘焙爱好者": {
|
||||||
|
"topics": [
|
||||||
|
"烘焙教程", "0失败甜品", "下午茶推荐", "蛋糕教程", "面包制作",
|
||||||
|
"饼干烘焙", "奶油裱花", "巧克力甜品", "网红甜品", "便当制作",
|
||||||
|
"早餐食谱", "咖啡配甜品", "节日甜品", "低卡甜品",
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"烘焙", "甜品", "蛋糕", "面包", "下午茶", "曲奇",
|
||||||
|
"裱花", "抹茶", "巧克力", "奶油", "食谱", "烤箱",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"数码科技女生": {
|
||||||
|
"topics": [
|
||||||
|
"iPad生产力", "手机摄影技巧", "好用App推荐", "电子产品测评",
|
||||||
|
"桌面布置", "数码好物", "耳机测评", "平板学习", "生产力工具",
|
||||||
|
"手机壳推荐", "充电设备", "智能家居",
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"iPad", "App推荐", "数码", "测评", "手机", "耳机",
|
||||||
|
"桌面", "科技", "电子产品", "平板", "生产力", "充电",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"小镇姑娘在大城市打拼": {
|
||||||
|
"topics": [
|
||||||
|
"省钱攻略", "成长日记", "平价好物", "租房改造", "副业分享",
|
||||||
|
"理财入门", "独居生活", "面试技巧", "通勤穿搭", "自律生活",
|
||||||
|
"城市生存指南", "女性成长", "攒钱计划",
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"省钱", "平价", "租房", "副业", "理财", "成长",
|
||||||
|
"自律", "打工", "攒钱", "面试", "独居", "北漂",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"中医养生爱好者": {
|
||||||
|
"topics": [
|
||||||
|
"节气养生", "食疗方子", "泡脚养生", "体质调理", "艾灸",
|
||||||
|
"中药茶饮", "作息调整", "经络按摩", "养胃食谱", "祛湿方法",
|
||||||
|
"睡眠改善", "女性调理", "养生汤", "二十四节气",
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"养生", "食疗", "泡脚", "中医", "艾灸", "祛湿",
|
||||||
|
"节气", "体质", "养胃", "经络", "调理", "药膳",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"二次元coser": {
|
||||||
|
"topics": [
|
||||||
|
"cos日常", "动漫周边", "漫展攻略", "cos化妆教程", "假发造型",
|
||||||
|
"lolita穿搭", "二次元好物", "手办收藏", "动漫推荐", "cos道具制作",
|
||||||
|
"jk穿搭", "谷子收藏", "二次元摄影",
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"cos", "动漫", "二次元", "漫展", "lolita", "手办",
|
||||||
|
"jk", "假发", "谷子", "周边", "番剧", "coser",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"北漂程序媛": {
|
||||||
|
"topics": [
|
||||||
|
"高效工作法", "程序员日常", "好用App推荐", "副业分享", "自律生活",
|
||||||
|
"时间管理", "iPad生产力", "解压方式", "通勤穿搭", "理财入门",
|
||||||
|
"独居生活", "技术学习", "面试经验", "桌面布置",
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"程序员", "高效", "App推荐", "自律", "副业", "iPad",
|
||||||
|
"技术", "工作", "北漂", "面试", "代码", "桌面",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"复古穿搭博主": {
|
||||||
|
"topics": [
|
||||||
|
"vintage风穿搭", "中古饰品", "复古妆容", "二手vintage", "古着穿搭",
|
||||||
|
"法式穿搭", "复古包包", "跳蚤市场", "旧物改造", "港风穿搭",
|
||||||
|
"文艺穿搭", "配饰搭配", "vintage探店",
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"vintage", "复古", "中古", "古着", "港风", "法式",
|
||||||
|
"饰品", "二手", "旧物", "跳蚤市场", "复古穿搭", "文艺",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"考研上岸学姐": {
|
||||||
|
"topics": [
|
||||||
|
"考研经验", "英语学习方法", "书单推荐", "时间管理", "自律生活",
|
||||||
|
"考研择校", "政治复习", "数学刷题", "考研英语", "复试经验",
|
||||||
|
"专业课复习", "考研心态", "背诵技巧", "刷题方法",
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"考研", "英语学习", "书单", "自律", "学习方法", "上岸",
|
||||||
|
"刷题", "备考", "复习", "笔记", "时间管理", "择校",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"新手养猫人": {
|
||||||
|
"topics": [
|
||||||
|
"养猫日常", "猫粮测评", "猫咪用品", "新手养宠指南", "猫咪健康",
|
||||||
|
"猫咪行为", "驱虫攻略", "猫砂测评", "猫玩具推荐", "猫咪拍照",
|
||||||
|
"多猫家庭", "领养代替购买", "猫咪绝育",
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"养猫", "猫粮", "猫咪", "宠物", "猫砂", "驱虫",
|
||||||
|
"铲屎官", "喵喵", "猫玩具", "猫零食", "新手养猫", "猫咪日常",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"咖啡重度爱好者": {
|
||||||
|
"topics": [
|
||||||
|
"咖啡探店", "手冲咖啡", "咖啡豆推荐", "咖啡器具", "拿铁艺术",
|
||||||
|
"家庭咖啡", "咖啡配甜品", "独立咖啡馆", "冷萃咖啡", "咖啡知识",
|
||||||
|
"意式咖啡", "探店打卡", "咖啡拉花",
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"咖啡", "手冲", "拿铁", "探店", "咖啡豆", "美式",
|
||||||
|
"咖啡馆", "意式", "冷萃", "拉花", "咖啡器具", "独立咖啡馆",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"极简主义生活家": {
|
||||||
|
"topics": [
|
||||||
|
"断舍离", "极简生活", "收纳技巧", "高质量生活", "减法生活",
|
||||||
|
"胶囊衣橱", "极简护肤", "环保生活", "数字断舍离", "极简穿搭",
|
||||||
|
"极简房间", "消费降级", "物欲管理",
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"断舍离", "极简", "收纳", "高质量", "减法", "胶囊衣橱",
|
||||||
|
"简约", "环保", "整理", "少即是多", "极简风", "质感",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"汉服爱好者": {
|
||||||
|
"topics": [
|
||||||
|
"汉服穿搭", "国风穿搭", "传统文化", "汉服发型", "汉服配饰",
|
||||||
|
"汉服拍照", "古风妆容", "汉服日常", "汉服科普", "形制科普",
|
||||||
|
"古风摄影", "新中式穿搭", "汉服探店",
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"汉服", "国风", "传统文化", "古风", "新中式", "形制",
|
||||||
|
"发簪", "明制", "宋制", "唐制", "汉服日常", "古风摄影",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"插画师小姐姐": {
|
||||||
|
"topics": [
|
||||||
|
"手绘教程", "创作灵感", "iPad绘画", "插画分享", "水彩教程",
|
||||||
|
"Procreate技巧", "配色方案", "角色设计", "头像绘制", "手账素材",
|
||||||
|
"接稿经验", "画师日常", "绘画工具推荐",
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"插画", "手绘", "Procreate", "画画", "iPad绘画", "水彩",
|
||||||
|
"配色", "创作", "画师", "手账", "教程", "素材",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"海归女孩": {
|
||||||
|
"topics": [
|
||||||
|
"中西文化差异", "海外生活", "留学经验", "英语学习方法", "海归求职",
|
||||||
|
"旅行攻略", "异国美食", "海外好物", "文化冲击", "语言学习",
|
||||||
|
"签证攻略", "海归适应", "国外探店",
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"留学", "海归", "英语", "海外", "文化差异", "旅行",
|
||||||
|
"异国", "签证", "语言", "出国", "求职", "国外",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"瑜伽老师": {
|
||||||
|
"topics": [
|
||||||
|
"瑜伽入门", "冥想练习", "体态矫正", "呼吸法", "居家瑜伽",
|
||||||
|
"拉伸教程", "肩颈放松", "瑜伽体式", "自律生活", "身心灵",
|
||||||
|
"瑜伽穿搭", "晨练瑜伽", "睡前瑜伽",
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"瑜伽", "冥想", "体态", "拉伸", "放松", "呼吸",
|
||||||
|
"柔韧", "健康", "自律", "晨练", "入门", "体式",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"美甲设计师": {
|
||||||
|
"topics": [
|
||||||
|
"美甲教程", "流行甲型", "美甲合集", "简约美甲", "法式美甲",
|
||||||
|
"手绘美甲", "季节美甲", "显白美甲", "美甲配色", "短甲美甲",
|
||||||
|
"新娘美甲", "美甲工具推荐", "日式美甲",
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"美甲", "甲型", "法式美甲", "手绘", "显白", "短甲",
|
||||||
|
"指甲", "美甲教程", "配色", "日式美甲", "腮红甲", "猫眼甲",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"家居软装设计师": {
|
||||||
|
"topics": [
|
||||||
|
"小户型改造", "氛围感布置", "软装搭配", "家居好物", "收纳技巧",
|
||||||
|
"客厅布置", "卧室改造", "灯光设计", "绿植布置", "装修避坑",
|
||||||
|
"北欧风格", "ins风家居", "墙面装饰",
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"家居", "软装", "改造", "收纳", "氛围感", "小户型",
|
||||||
|
"装修", "灯光", "绿植", "北欧", "ins风", "布置",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 为"随机人设"使用的全量池(兼容旧逻辑)
|
||||||
DEFAULT_TOPICS = [
|
DEFAULT_TOPICS = [
|
||||||
# 穿搭类
|
# 穿搭类
|
||||||
"春季穿搭", "通勤穿搭", "约会穿搭", "显瘦穿搭", "小个子穿搭",
|
"春季穿搭", "通勤穿搭", "约会穿搭", "显瘦穿搭", "小个子穿搭",
|
||||||
@ -1064,7 +1384,7 @@ DEFAULT_STYLES = [
|
|||||||
"知识科普", "经验分享", "清单合集", "对比测评", "沉浸式体验",
|
"知识科普", "经验分享", "清单合集", "对比测评", "沉浸式体验",
|
||||||
]
|
]
|
||||||
|
|
||||||
# ================= 评论关键词池 =================
|
# 全量评论关键词池(兼容旧逻辑 / 随机人设)
|
||||||
DEFAULT_COMMENT_KEYWORDS = [
|
DEFAULT_COMMENT_KEYWORDS = [
|
||||||
# 穿搭时尚
|
# 穿搭时尚
|
||||||
"穿搭", "ootd", "早春穿搭", "通勤穿搭", "显瘦", "小个子穿搭",
|
"穿搭", "ootd", "早春穿搭", "通勤穿搭", "显瘦", "小个子穿搭",
|
||||||
@ -1087,6 +1407,77 @@ DEFAULT_COMMENT_KEYWORDS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _match_persona_pools(persona_text: str) -> dict | None:
|
||||||
|
"""根据人设文本模糊匹配对应的关键词池和主题池
|
||||||
|
返回 {"topics": [...], "keywords": [...]} 或 None(未匹配)
|
||||||
|
"""
|
||||||
|
if not persona_text or persona_text == RANDOM_PERSONA_LABEL:
|
||||||
|
return None
|
||||||
|
# 精确匹配
|
||||||
|
for key, pools in PERSONA_POOL_MAP.items():
|
||||||
|
if key in persona_text or persona_text in key:
|
||||||
|
return pools
|
||||||
|
# 关键词模糊匹配
|
||||||
|
_CATEGORY_HINTS = {
|
||||||
|
"时尚|穿搭|搭配|衣服": "温柔知性的时尚博主",
|
||||||
|
"大学|学生|校园": "元气满满的大学生",
|
||||||
|
"白领|职场|通勤|上班": "30岁都市白领丽人",
|
||||||
|
"妈妈|育儿|宝宝|母婴": "精致妈妈",
|
||||||
|
"摄影|旅行|旅游|文艺": "文艺青年摄影师",
|
||||||
|
"健身|运动|减脂|增肌|营养": "健身达人营养师",
|
||||||
|
"美妆|化妆|护肤|美白": "资深美妆博主",
|
||||||
|
"独居|租房|一人": "独居女孩",
|
||||||
|
"烘焙|甜品|蛋糕|面包": "甜品烘焙爱好者",
|
||||||
|
"数码|科技|App|电子": "数码科技女生",
|
||||||
|
"小镇|打拼|省钱|攒钱": "小镇姑娘在大城市打拼",
|
||||||
|
"中医|养生|食疗|节气": "中医养生爱好者",
|
||||||
|
"二次元|cos|动漫|漫展": "二次元coser",
|
||||||
|
"程序|代码|开发|码农": "北漂程序媛",
|
||||||
|
"复古|vintage|中古|古着": "复古穿搭博主",
|
||||||
|
"考研|备考|上岸|学习方法": "考研上岸学姐",
|
||||||
|
"猫|铲屎|喵": "新手养猫人",
|
||||||
|
"咖啡|手冲|拿铁": "咖啡重度爱好者",
|
||||||
|
"极简|断舍离|简约": "极简主义生活家",
|
||||||
|
"汉服|国风|传统文化": "汉服爱好者",
|
||||||
|
"插画|手绘|画画|绘画": "插画师小姐姐",
|
||||||
|
"海归|留学|海外": "海归女孩",
|
||||||
|
"瑜伽|冥想|身心灵": "瑜伽老师",
|
||||||
|
"美甲|甲型|指甲": "美甲设计师",
|
||||||
|
"家居|软装|装修|改造": "家居软装设计师",
|
||||||
|
}
|
||||||
|
for hints, persona_key in _CATEGORY_HINTS.items():
|
||||||
|
if any(h in persona_text for h in hints.split("|")):
|
||||||
|
return PERSONA_POOL_MAP.get(persona_key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_persona_topics(persona_text: str) -> list[str]:
|
||||||
|
"""获取人设对应的主题池,未匹配则返回全量池"""
|
||||||
|
pools = _match_persona_pools(persona_text)
|
||||||
|
return pools["topics"] if pools else DEFAULT_TOPICS
|
||||||
|
|
||||||
|
|
||||||
|
def get_persona_keywords(persona_text: str) -> list[str]:
|
||||||
|
"""获取人设对应的评论关键词池,未匹配则返回全量池"""
|
||||||
|
pools = _match_persona_pools(persona_text)
|
||||||
|
return pools["keywords"] if pools else DEFAULT_COMMENT_KEYWORDS
|
||||||
|
|
||||||
|
|
||||||
|
def on_persona_changed(persona_text: str):
|
||||||
|
"""人设切换时联动更新评论关键词池和主题池"""
|
||||||
|
keywords = get_persona_keywords(persona_text)
|
||||||
|
topics = get_persona_topics(persona_text)
|
||||||
|
keywords_str = ", ".join(keywords)
|
||||||
|
topics_str = ", ".join(topics)
|
||||||
|
matched = _match_persona_pools(persona_text)
|
||||||
|
if matched:
|
||||||
|
label = persona_text[:15] if len(persona_text) > 15 else persona_text
|
||||||
|
hint = f"✅ 已切换至「{label}」专属关键词/主题池"
|
||||||
|
else:
|
||||||
|
hint = "ℹ️ 使用通用全量关键词/主题池"
|
||||||
|
return keywords_str, topics_str, hint
|
||||||
|
|
||||||
|
|
||||||
def _auto_log_append(msg: str):
|
def _auto_log_append(msg: str):
|
||||||
"""记录自动化日志"""
|
"""记录自动化日志"""
|
||||||
ts = datetime.now().strftime("%H:%M:%S")
|
ts = datetime.now().strftime("%H:%M:%S")
|
||||||
@ -1122,7 +1513,9 @@ def auto_comment_once(keywords_str, mcp_url, model, persona_text):
|
|||||||
return f"🚫 今日评论已达上限 ({DAILY_LIMITS['comments']})"
|
return f"🚫 今日评论已达上限 ({DAILY_LIMITS['comments']})"
|
||||||
|
|
||||||
persona_text = _resolve_persona(persona_text)
|
persona_text = _resolve_persona(persona_text)
|
||||||
keywords = [k.strip() for k in keywords_str.split(",") if k.strip()] if keywords_str else DEFAULT_COMMENT_KEYWORDS
|
# 如果用户未手动修改关键词池,则使用人设匹配的专属关键词池
|
||||||
|
persona_keywords = get_persona_keywords(persona_text)
|
||||||
|
keywords = [k.strip() for k in keywords_str.split(",") if k.strip()] if keywords_str else persona_keywords
|
||||||
keyword = random.choice(keywords)
|
keyword = random.choice(keywords)
|
||||||
_auto_log_append(f"🔍 搜索关键词: {keyword}")
|
_auto_log_append(f"🔍 搜索关键词: {keyword}")
|
||||||
|
|
||||||
@ -1367,9 +1760,9 @@ def auto_favorite_once(keywords_str, fav_count, mcp_url):
|
|||||||
return f"❌ 收藏失败: {e}"
|
return f"❌ 收藏失败: {e}"
|
||||||
|
|
||||||
|
|
||||||
def _auto_publish_with_log(topics_str, mcp_url, sd_url_val, sd_model_name, model):
|
def _auto_publish_with_log(topics_str, mcp_url, sd_url_val, sd_model_name, model, face_swap_on=False):
|
||||||
"""一键发布 + 同步刷新日志"""
|
"""一键发布 + 同步刷新日志"""
|
||||||
msg = auto_publish_once(topics_str, mcp_url, sd_url_val, sd_model_name, model)
|
msg = auto_publish_once(topics_str, mcp_url, sd_url_val, sd_model_name, model, face_swap_on=face_swap_on)
|
||||||
return msg, get_auto_log()
|
return msg, get_auto_log()
|
||||||
|
|
||||||
|
|
||||||
@ -1540,7 +1933,7 @@ def auto_reply_once(max_replies, mcp_url, model, persona_text):
|
|||||||
return f"❌ 自动回复失败: {e}"
|
return f"❌ 自动回复失败: {e}"
|
||||||
|
|
||||||
|
|
||||||
def auto_publish_once(topics_str, mcp_url, sd_url_val, sd_model_name, model):
|
def auto_publish_once(topics_str, mcp_url, sd_url_val, sd_model_name, model, face_swap_on=False):
|
||||||
"""一键发布:自动生成文案 → 生成图片 → 本地备份 → 发布到小红书(含限额)"""
|
"""一键发布:自动生成文案 → 生成图片 → 本地备份 → 发布到小红书(含限额)"""
|
||||||
try:
|
try:
|
||||||
if _is_in_cooldown():
|
if _is_in_cooldown():
|
||||||
@ -1551,7 +1944,7 @@ def auto_publish_once(topics_str, mcp_url, sd_url_val, sd_model_name, model):
|
|||||||
topics = [t.strip() for t in topics_str.split(",") if t.strip()] if topics_str else DEFAULT_TOPICS
|
topics = [t.strip() for t in topics_str.split(",") if t.strip()] if topics_str else DEFAULT_TOPICS
|
||||||
topic = random.choice(topics)
|
topic = random.choice(topics)
|
||||||
style = random.choice(DEFAULT_STYLES)
|
style = random.choice(DEFAULT_STYLES)
|
||||||
_auto_log_append(f"📝 主题: {topic} | 风格: {style}")
|
_auto_log_append(f"📝 主题: {topic} | 风格: {style} (主题池: {len(topics)} 个)")
|
||||||
|
|
||||||
# 生成文案
|
# 生成文案
|
||||||
api_key, base_url, _ = _get_llm_config()
|
api_key, base_url, _ = _get_llm_config()
|
||||||
@ -1575,7 +1968,15 @@ def auto_publish_once(topics_str, mcp_url, sd_url_val, sd_model_name, model):
|
|||||||
return "❌ SD WebUI 未连接或未选择模型,请先在全局设置中连接"
|
return "❌ SD WebUI 未连接或未选择模型,请先在全局设置中连接"
|
||||||
|
|
||||||
sd_svc = SDService(sd_url_val)
|
sd_svc = SDService(sd_url_val)
|
||||||
images = sd_svc.txt2img(prompt=sd_prompt, model=sd_model_name)
|
# 自动发布也支持换脸
|
||||||
|
face_image = None
|
||||||
|
if face_swap_on:
|
||||||
|
face_image = SDService.load_face_image()
|
||||||
|
if face_image:
|
||||||
|
_auto_log_append("🎭 换脸已启用")
|
||||||
|
else:
|
||||||
|
_auto_log_append("⚠️ 换脸已启用但未找到头像,跳过换脸")
|
||||||
|
images = sd_svc.txt2img(prompt=sd_prompt, model=sd_model_name, face_image=face_image)
|
||||||
if not images:
|
if not images:
|
||||||
_record_error()
|
_record_error()
|
||||||
return "❌ 图片生成失败:没有返回图片"
|
return "❌ 图片生成失败:没有返回图片"
|
||||||
@ -1648,7 +2049,7 @@ def _scheduler_loop(comment_enabled, publish_enabled, reply_enabled, like_enable
|
|||||||
fav_min, fav_max, fav_count_per_run,
|
fav_min, fav_max, fav_count_per_run,
|
||||||
op_start_hour, op_end_hour,
|
op_start_hour, op_end_hour,
|
||||||
keywords, topics, mcp_url, sd_url_val, sd_model_name,
|
keywords, topics, mcp_url, sd_url_val, sd_model_name,
|
||||||
model, persona_text):
|
model, persona_text, face_swap_on=False):
|
||||||
"""后台定时调度循环(含运营时段、冷却、收藏、统计)"""
|
"""后台定时调度循环(含运营时段、冷却、收藏、统计)"""
|
||||||
_auto_log_append("🤖 自动化调度器已启动")
|
_auto_log_append("🤖 自动化调度器已启动")
|
||||||
_auto_log_append(f"⏰ 运营时段: {int(op_start_hour)}:00 - {int(op_end_hour)}:00")
|
_auto_log_append(f"⏰ 运营时段: {int(op_start_hour)}:00 - {int(op_end_hour)}:00")
|
||||||
@ -1742,7 +2143,7 @@ def _scheduler_loop(comment_enabled, publish_enabled, reply_enabled, like_enable
|
|||||||
if publish_enabled and now >= next_publish:
|
if publish_enabled and now >= next_publish:
|
||||||
try:
|
try:
|
||||||
_auto_log_append("--- 🔄 执行自动发布 ---")
|
_auto_log_append("--- 🔄 执行自动发布 ---")
|
||||||
msg = auto_publish_once(topics, mcp_url, sd_url_val, sd_model_name, model)
|
msg = auto_publish_once(topics, mcp_url, sd_url_val, sd_model_name, model, face_swap_on=face_swap_on)
|
||||||
_auto_log_append(msg)
|
_auto_log_append(msg)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_auto_log_append(f"❌ 自动发布异常: {e}")
|
_auto_log_append(f"❌ 自动发布异常: {e}")
|
||||||
@ -1781,7 +2182,7 @@ def start_scheduler(comment_on, publish_on, reply_on, like_on, favorite_on,
|
|||||||
fav_min, fav_max, fav_count_per_run,
|
fav_min, fav_max, fav_count_per_run,
|
||||||
op_start_hour, op_end_hour,
|
op_start_hour, op_end_hour,
|
||||||
keywords, topics, mcp_url, sd_url_val, sd_model_name,
|
keywords, topics, mcp_url, sd_url_val, sd_model_name,
|
||||||
model, persona_text):
|
model, persona_text, face_swap_on=False):
|
||||||
"""启动定时自动化"""
|
"""启动定时自动化"""
|
||||||
global _auto_thread
|
global _auto_thread
|
||||||
if _auto_running.is_set():
|
if _auto_running.is_set():
|
||||||
@ -1807,6 +2208,7 @@ def start_scheduler(comment_on, publish_on, reply_on, like_on, favorite_on,
|
|||||||
op_start_hour, op_end_hour,
|
op_start_hour, op_end_hour,
|
||||||
keywords, topics, mcp_url, sd_url_val, sd_model_name,
|
keywords, topics, mcp_url, sd_url_val, sd_model_name,
|
||||||
model, persona_text),
|
model, persona_text),
|
||||||
|
kwargs={"face_swap_on": face_swap_on},
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
_auto_thread.start()
|
_auto_thread.start()
|
||||||
@ -2066,6 +2468,38 @@ with gr.Blocks(
|
|||||||
)
|
)
|
||||||
status_bar = gr.Markdown("🔄 等待连接...")
|
status_bar = gr.Markdown("🔄 等待连接...")
|
||||||
|
|
||||||
|
gr.Markdown("---")
|
||||||
|
gr.Markdown("#### 🎭 AI 换脸 (ReActor)")
|
||||||
|
gr.Markdown(
|
||||||
|
"> 上传你的头像,生成含人物的图片时自动替换为你的脸\n"
|
||||||
|
"> 需要 SD WebUI 已安装 [ReActor](https://github.com/Gourieff/sd-webui-reactor) 扩展"
|
||||||
|
)
|
||||||
|
with gr.Row():
|
||||||
|
face_image_input = gr.Image(
|
||||||
|
label="上传头像 (正面清晰照片效果最佳)",
|
||||||
|
type="pil",
|
||||||
|
height=180,
|
||||||
|
scale=1,
|
||||||
|
)
|
||||||
|
face_image_preview = gr.Image(
|
||||||
|
label="当前头像",
|
||||||
|
type="pil",
|
||||||
|
height=180,
|
||||||
|
interactive=False,
|
||||||
|
value=SDService.load_face_image(),
|
||||||
|
scale=1,
|
||||||
|
)
|
||||||
|
with gr.Row():
|
||||||
|
btn_save_face = gr.Button("💾 保存头像", variant="primary", size="sm")
|
||||||
|
face_swap_toggle = gr.Checkbox(
|
||||||
|
label="🎭 生成图片时启用 AI 换脸",
|
||||||
|
value=os.path.isfile(FACE_IMAGE_PATH),
|
||||||
|
interactive=True,
|
||||||
|
)
|
||||||
|
face_status = gr.Markdown(
|
||||||
|
"✅ 头像已就绪" if os.path.isfile(FACE_IMAGE_PATH) else "ℹ️ 尚未设置头像"
|
||||||
|
)
|
||||||
|
|
||||||
gr.Markdown("---")
|
gr.Markdown("---")
|
||||||
gr.Markdown("#### 🖥️ 系统设置")
|
gr.Markdown("#### 🖥️ 系统设置")
|
||||||
with gr.Row():
|
with gr.Row():
|
||||||
@ -2415,6 +2849,9 @@ with gr.Blocks(
|
|||||||
"> 一键评论引流 + 一键点赞 + 一键收藏 + 一键回复 + 一键发布 + 随机定时全自动\n\n"
|
"> 一键评论引流 + 一键点赞 + 一键收藏 + 一键回复 + 一键发布 + 随机定时全自动\n\n"
|
||||||
"⚠️ **注意**: 请确保已连接 LLM、SD WebUI 和 MCP 服务"
|
"⚠️ **注意**: 请确保已连接 LLM、SD WebUI 和 MCP 服务"
|
||||||
)
|
)
|
||||||
|
persona_pool_hint = gr.Markdown(
|
||||||
|
value=f"🎭 当前人设池: **{config.get('persona', '随机')[:20]}** → 关键词/主题池已匹配",
|
||||||
|
)
|
||||||
|
|
||||||
with gr.Row():
|
with gr.Row():
|
||||||
# 左栏: 一键操作
|
# 左栏: 一键操作
|
||||||
@ -2425,9 +2862,9 @@ with gr.Blocks(
|
|||||||
"每次随机选关键词搜索,从结果中随机选笔记"
|
"每次随机选关键词搜索,从结果中随机选笔记"
|
||||||
)
|
)
|
||||||
auto_comment_keywords = gr.Textbox(
|
auto_comment_keywords = gr.Textbox(
|
||||||
label="评论关键词池 (逗号分隔)",
|
label="评论关键词池 (逗号分隔,随人设自动切换)",
|
||||||
value=", ".join(DEFAULT_COMMENT_KEYWORDS),
|
value=", ".join(get_persona_keywords(config.get("persona", ""))),
|
||||||
placeholder="关键词1, 关键词2, ...",
|
placeholder="关键词1, 关键词2, ... (切换人设自动更新)",
|
||||||
)
|
)
|
||||||
btn_auto_comment = gr.Button(
|
btn_auto_comment = gr.Button(
|
||||||
"💬 一键评论 (单次)", variant="primary", size="lg",
|
"💬 一键评论 (单次)", variant="primary", size="lg",
|
||||||
@ -2482,9 +2919,9 @@ with gr.Blocks(
|
|||||||
"> 随机选主题+风格 → AI 生成文案 → SD 生成图片 → 自动发布"
|
"> 随机选主题+风格 → AI 生成文案 → SD 生成图片 → 自动发布"
|
||||||
)
|
)
|
||||||
auto_publish_topics = gr.Textbox(
|
auto_publish_topics = gr.Textbox(
|
||||||
label="主题池 (逗号分隔)",
|
label="主题池 (逗号分隔,随人设自动切换)",
|
||||||
value=", ".join(random.sample(DEFAULT_TOPICS, min(15, len(DEFAULT_TOPICS)))),
|
value=", ".join(get_persona_topics(config.get("persona", ""))),
|
||||||
placeholder="主题会从池中随机选取,可自行修改",
|
placeholder="主题会从池中随机选取,切换人设自动更新",
|
||||||
)
|
)
|
||||||
btn_auto_publish = gr.Button(
|
btn_auto_publish = gr.Button(
|
||||||
"🚀 一键发布 (单次)", variant="primary", size="lg",
|
"🚀 一键发布 (单次)", variant="primary", size="lg",
|
||||||
@ -2642,6 +3079,13 @@ with gr.Blocks(
|
|||||||
outputs=[status_bar],
|
outputs=[status_bar],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ---- 头像/换脸管理 ----
|
||||||
|
btn_save_face.click(
|
||||||
|
fn=upload_face_image,
|
||||||
|
inputs=[face_image_input],
|
||||||
|
outputs=[face_image_preview, face_status],
|
||||||
|
)
|
||||||
|
|
||||||
# ---- Tab 1: 内容创作 ----
|
# ---- Tab 1: 内容创作 ----
|
||||||
btn_gen_copy.click(
|
btn_gen_copy.click(
|
||||||
fn=generate_copy,
|
fn=generate_copy,
|
||||||
@ -2651,7 +3095,8 @@ with gr.Blocks(
|
|||||||
|
|
||||||
btn_gen_img.click(
|
btn_gen_img.click(
|
||||||
fn=generate_images,
|
fn=generate_images,
|
||||||
inputs=[sd_url, res_prompt, neg_prompt, sd_model, steps, cfg_scale],
|
inputs=[sd_url, res_prompt, neg_prompt, sd_model, steps, cfg_scale,
|
||||||
|
face_swap_toggle, face_image_preview],
|
||||||
outputs=[gallery, state_images, status_bar],
|
outputs=[gallery, state_images, status_bar],
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -2798,6 +3243,13 @@ with gr.Blocks(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ---- Tab 6: 自动运营 ----
|
# ---- Tab 6: 自动运营 ----
|
||||||
|
# 人设切换 → 联动更新评论关键词池和主题池
|
||||||
|
persona.change(
|
||||||
|
fn=on_persona_changed,
|
||||||
|
inputs=[persona],
|
||||||
|
outputs=[auto_comment_keywords, auto_publish_topics, persona_pool_hint],
|
||||||
|
)
|
||||||
|
|
||||||
btn_auto_comment.click(
|
btn_auto_comment.click(
|
||||||
fn=_auto_comment_with_log,
|
fn=_auto_comment_with_log,
|
||||||
inputs=[auto_comment_keywords, mcp_url, llm_model, persona],
|
inputs=[auto_comment_keywords, mcp_url, llm_model, persona],
|
||||||
@ -2820,7 +3272,7 @@ with gr.Blocks(
|
|||||||
)
|
)
|
||||||
btn_auto_publish.click(
|
btn_auto_publish.click(
|
||||||
fn=_auto_publish_with_log,
|
fn=_auto_publish_with_log,
|
||||||
inputs=[auto_publish_topics, mcp_url, sd_url, sd_model, llm_model],
|
inputs=[auto_publish_topics, mcp_url, sd_url, sd_model, llm_model, face_swap_toggle],
|
||||||
outputs=[auto_publish_result, auto_log_display],
|
outputs=[auto_publish_result, auto_log_display],
|
||||||
)
|
)
|
||||||
btn_start_sched.click(
|
btn_start_sched.click(
|
||||||
@ -2833,7 +3285,8 @@ with gr.Blocks(
|
|||||||
sched_fav_min, sched_fav_max, sched_fav_count,
|
sched_fav_min, sched_fav_max, sched_fav_count,
|
||||||
sched_start_hour, sched_end_hour,
|
sched_start_hour, sched_end_hour,
|
||||||
auto_comment_keywords, auto_publish_topics,
|
auto_comment_keywords, auto_publish_topics,
|
||||||
mcp_url, sd_url, sd_model, llm_model, persona],
|
mcp_url, sd_url, sd_model, llm_model, persona,
|
||||||
|
face_swap_toggle],
|
||||||
outputs=[sched_result],
|
outputs=[sched_result],
|
||||||
)
|
)
|
||||||
btn_stop_sched.click(
|
btn_stop_sched.click(
|
||||||
|
|||||||
BIN
my_face.png
Normal file
BIN
my_face.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.4 MiB |
BIN
myself.jpg
Normal file
BIN
myself.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
114
sd_service.py
114
sd_service.py
@ -1,16 +1,20 @@
|
|||||||
"""
|
"""
|
||||||
Stable Diffusion 服务模块
|
Stable Diffusion 服务模块
|
||||||
封装对 SD WebUI API 的调用,支持 txt2img 和 img2img
|
封装对 SD WebUI API 的调用,支持 txt2img 和 img2img,支持 ReActor 换脸
|
||||||
"""
|
"""
|
||||||
import requests
|
import requests
|
||||||
import base64
|
import base64
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SD_TIMEOUT = 900 # 图片生成可能需要较长时间
|
SD_TIMEOUT = 1800 # 图片生成可能需要较长时间
|
||||||
|
|
||||||
|
# 头像文件默认保存路径
|
||||||
|
FACE_IMAGE_PATH = os.path.join(os.path.dirname(__file__), "my_face.png")
|
||||||
|
|
||||||
# 默认反向提示词(针对 JuggernautXL / SDXL 优化,偏向东方审美)
|
# 默认反向提示词(针对 JuggernautXL / SDXL 优化,偏向东方审美)
|
||||||
DEFAULT_NEGATIVE = (
|
DEFAULT_NEGATIVE = (
|
||||||
@ -31,6 +35,100 @@ class SDService:
|
|||||||
def __init__(self, sd_url: str = "http://127.0.0.1:7860"):
|
def __init__(self, sd_url: str = "http://127.0.0.1:7860"):
|
||||||
self.sd_url = sd_url.rstrip("/")
|
self.sd_url = sd_url.rstrip("/")
|
||||||
|
|
||||||
|
# ---------- 工具方法 ----------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _image_to_base64(img: Image.Image) -> str:
|
||||||
|
"""PIL Image → base64 字符串"""
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="PNG")
|
||||||
|
return base64.b64encode(buf.getvalue()).decode("utf-8")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def load_face_image(path: str = None) -> Image.Image | None:
|
||||||
|
"""加载头像图片,不存在则返回 None"""
|
||||||
|
path = path or FACE_IMAGE_PATH
|
||||||
|
if path and os.path.isfile(path):
|
||||||
|
try:
|
||||||
|
return Image.open(path).convert("RGB")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("头像加载失败: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def save_face_image(img: Image.Image, path: str = None) -> str:
|
||||||
|
"""保存头像图片,返回保存路径"""
|
||||||
|
path = path or FACE_IMAGE_PATH
|
||||||
|
img = img.convert("RGB")
|
||||||
|
img.save(path, format="PNG")
|
||||||
|
logger.info("头像已保存: %s", path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _build_reactor_args(self, face_image: Image.Image) -> dict:
|
||||||
|
"""构建 ReActor 换脸参数(alwayson_scripts 格式)
|
||||||
|
|
||||||
|
参数索引对照 (reactor script-info):
|
||||||
|
0: source_image (base64) 1: enable 2: source_faces
|
||||||
|
3: target_faces 4: model 5: restore_face
|
||||||
|
6: restore_visibility 7: restore_first 8: upscaler
|
||||||
|
9: scale 10: upscaler_vis 11: swap_in_source
|
||||||
|
12: swap_in_generated 13: log_level 14: gender_source
|
||||||
|
15: gender_target 16: save_original 17: codeformer_weight
|
||||||
|
18: source_hash_check 19: target_hash_check 20: exec_provider
|
||||||
|
21: face_mask_correction 22: select_source 23: face_model
|
||||||
|
24: source_folder 25: multiple_sources 26: random_image
|
||||||
|
27: force_upscale 28: threshold 29: max_faces
|
||||||
|
30: tab_single
|
||||||
|
"""
|
||||||
|
face_b64 = self._image_to_base64(face_image)
|
||||||
|
return {
|
||||||
|
"reactor": {
|
||||||
|
"args": [
|
||||||
|
face_b64, # 0: source image (base64)
|
||||||
|
True, # 1: enable ReActor
|
||||||
|
"0", # 2: source face index
|
||||||
|
"0", # 3: target face index
|
||||||
|
"inswapper_128.onnx", # 4: swap model
|
||||||
|
"CodeFormer", # 5: restore face method
|
||||||
|
1, # 6: restore face visibility
|
||||||
|
True, # 7: restore face first, then upscale
|
||||||
|
"None", # 8: upscaler
|
||||||
|
1, # 9: scale
|
||||||
|
1, # 10: upscaler visibility
|
||||||
|
False, # 11: swap in source
|
||||||
|
True, # 12: swap in generated
|
||||||
|
"Minimum", # 13: log level
|
||||||
|
"No", # 14: gender detection (source)
|
||||||
|
"No", # 15: gender detection (target)
|
||||||
|
False, # 16: save original
|
||||||
|
0.6, # 17: CodeFormer weight (fidelity)
|
||||||
|
True, # 18: source hash check
|
||||||
|
False, # 19: target hash check
|
||||||
|
"CUDA", # 20: execution provider
|
||||||
|
True, # 21: face mask correction
|
||||||
|
"Image(s)", # 22: select source type
|
||||||
|
"None", # 23: face model name
|
||||||
|
"", # 24: source folder
|
||||||
|
None, # 25: multiple source images
|
||||||
|
False, # 26: random image
|
||||||
|
False, # 27: force upscale
|
||||||
|
0.5, # 28: detection threshold
|
||||||
|
0, # 29: max faces (0 = no limit)
|
||||||
|
"tab_single", # 30: tab
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def has_reactor(self) -> bool:
|
||||||
|
"""检查 SD WebUI 是否安装了 ReActor 扩展"""
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{self.sd_url}/sdapi/v1/scripts", timeout=5)
|
||||||
|
scripts = resp.json()
|
||||||
|
all_scripts = scripts.get("txt2img", []) + scripts.get("img2img", [])
|
||||||
|
return any("reactor" in s.lower() for s in all_scripts)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
def check_connection(self) -> tuple[bool, str]:
|
def check_connection(self) -> tuple[bool, str]:
|
||||||
"""检查 SD 服务是否可用"""
|
"""检查 SD 服务是否可用"""
|
||||||
try:
|
try:
|
||||||
@ -74,8 +172,13 @@ class SDService:
|
|||||||
seed: int = -1,
|
seed: int = -1,
|
||||||
sampler_name: str = "DPM++ 2M",
|
sampler_name: str = "DPM++ 2M",
|
||||||
scheduler: str = "Karras",
|
scheduler: str = "Karras",
|
||||||
|
face_image: Image.Image = None,
|
||||||
) -> list[Image.Image]:
|
) -> list[Image.Image]:
|
||||||
"""文生图(参数针对 JuggernautXL 优化)"""
|
"""文生图(参数针对 JuggernautXL 优化)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
face_image: 头像 PIL Image,传入后自动启用 ReActor 换脸
|
||||||
|
"""
|
||||||
if model:
|
if model:
|
||||||
self.switch_model(model)
|
self.switch_model(model)
|
||||||
|
|
||||||
@ -92,6 +195,11 @@ class SDService:
|
|||||||
"scheduler": scheduler,
|
"scheduler": scheduler,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 如果提供了头像,通过 ReActor 换脸
|
||||||
|
if face_image is not None:
|
||||||
|
payload["alwayson_scripts"] = self._build_reactor_args(face_image)
|
||||||
|
logger.info("🎭 ReActor 换脸已启用")
|
||||||
|
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"{self.sd_url}/sdapi/v1/txt2img",
|
f"{self.sd_url}/sdapi/v1/txt2img",
|
||||||
json=payload,
|
json=payload,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user