feat(ai-outfit): multiple named prompt templates (§19.8)

- config_service: outfit_prompts.json load/save (utf-8-sig read, no-BOM write,
  corrupt/empty -> migrate legacy outfit_prompt.txt or seed 默认; always >=1;
  _normalize_prompts drops invalid). app_config gains outfit_prompt_name.
- ai_outfit_panel: 通用话术 group gets a template dropdown + 新建/另存为/重命名/
  删除 (keeps 插入标题/保存). Switching loads the template + refreshes preview;
  dirty edits prompt Save/Discard/Cancel before switching/new/save-as (cancel
  reverts the combo); unique names; can't delete the last one; selection
  persisted via config_changed; 开始生成 stores the editor into the template.
- tests: +6 config cases (14 total); offscreen CRUD/dirty/rename/delete verified;
  full suite (12 files) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 14:12:34 +08:00
co-authored by Claude Opus 4.8
parent 072b546f5f
commit 25b18ea342
4 changed files with 266 additions and 13 deletions
+53 -2
View File
@@ -26,13 +26,16 @@ DEFAULT_CONFIG = {
"outfit_resolution": "1K",
"outfit_quality": "均衡",
"outfit_retry_failed": False,
"outfit_prompt_name": "默认", # last-selected 话术模板 name (docs/11 §7.2)
}
_CONFIG_FILENAME = "app_config.json"
_AI_MODELS_FILENAME = "ai_models.json"
_OUTFIT_PROMPT_FILENAME = "outfit_prompt.txt"
_OUTFIT_PROMPT_FILENAME = "outfit_prompt.txt" # legacy single prompt (migrated)
_OUTFIT_PROMPTS_FILENAME = "outfit_prompts.json" # multi named templates (§7.2)
DEFAULT_OUTFIT_PROMPT_NAME = "默认"
# Default outfit prompt (docs/11 §7). Persisted to outfit_prompt.txt on first save.
# Default outfit prompt (docs/11 §7). Seeded into outfit_prompts.json on first run.
DEFAULT_OUTFIT_PROMPT = (
"为商品「{title}」生成人物上身实穿效果图:真人模特正面穿着这件衣服,"
"完整保留款式、版型、颜色与印花图案,自然光、纯色棚拍背景,"
@@ -139,3 +142,51 @@ def save_outfit_prompt(text):
logger.info("Outfit prompt saved to %s", prompt_file)
except OSError as exc:
logger.error("Failed to save outfit prompt to %s: %s", prompt_file, exc)
def _normalize_prompts(data):
"""Keep only valid {name, text} entries (non-empty name, string text)."""
if not isinstance(data, list):
return []
out = []
for item in data:
if isinstance(item, dict):
name = str(item.get("name", "")).strip()
text = item.get("text", "")
if name and isinstance(text, str):
out.append({"name": name, "text": text})
return out
def load_outfit_prompts():
"""Load named 话术 templates (docs/11 §7.2); always returns >= 1.
Missing/corrupt → migrate the legacy outfit_prompt.txt into a single
「默认」template, or seed it from DEFAULT_OUTFIT_PROMPT.
"""
from services.file_service import get_config_path
prompts_file = get_config_path(_OUTFIT_PROMPTS_FILENAME)
if prompts_file.exists():
try:
with open(str(prompts_file), encoding="utf-8-sig") as f:
prompts = _normalize_prompts(json.load(f))
if prompts:
return prompts
except (json.JSONDecodeError, ValueError, OSError) as exc:
logger.warning("Outfit prompts unreadable (%s): %s", exc, prompts_file)
# Seed / migrate (load_outfit_prompt reads the legacy txt or the default).
return [{"name": DEFAULT_OUTFIT_PROMPT_NAME, "text": load_outfit_prompt()}]
def save_outfit_prompts(prompts):
"""Persist named 话术 templates as JSON (utf-8, no BOM). Does not raise."""
from services.file_service import get_config_path
prompts_file = get_config_path(_OUTFIT_PROMPTS_FILENAME)
try:
prompts_file.parent.mkdir(parents=True, exist_ok=True)
with open(str(prompts_file), "w", encoding="utf-8") as f:
json.dump(list(prompts), f, ensure_ascii=False, indent=2)
logger.info("Outfit prompts saved to %s", prompts_file)
except OSError as exc:
logger.error("Failed to save outfit prompts to %s: %s", prompts_file, exc)