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
+43
View File
@@ -68,6 +68,49 @@ class TestOutfitConfigHelpers(unittest.TestCase):
raw = (self.config_dir / "outfit_prompt.txt").read_bytes()
self.assertFalse(raw.startswith(b"\xef\xbb\xbf"))
# -- outfit_prompts.json (multi templates, §7.2) --------------------
def test_prompts_seed_default_when_missing(self):
prompts = cs.load_outfit_prompts()
self.assertEqual(len(prompts), 1)
self.assertEqual(prompts[0]["name"], cs.DEFAULT_OUTFIT_PROMPT_NAME)
self.assertEqual(prompts[0]["text"], cs.DEFAULT_OUTFIT_PROMPT)
def test_prompts_migrate_legacy_txt(self):
(self.config_dir / "outfit_prompt.txt").write_text(
"旧话术 {title}", encoding="utf-8")
prompts = cs.load_outfit_prompts()
self.assertEqual(len(prompts), 1)
self.assertEqual(prompts[0]["name"], "默认")
self.assertEqual(prompts[0]["text"], "旧话术 {title}")
def test_prompts_roundtrip_and_no_bom(self):
data = [{"name": "A", "text": "甲 {title}"}, {"name": "B", "text": "乙"}]
cs.save_outfit_prompts(data)
self.assertEqual(cs.load_outfit_prompts(), data)
raw = (self.config_dir / "outfit_prompts.json").read_bytes()
self.assertFalse(raw.startswith(b"\xef\xbb\xbf"))
def test_prompts_corrupt_falls_back(self):
(self.config_dir / "outfit_prompts.json").write_text("{ bad", encoding="utf-8")
prompts = cs.load_outfit_prompts()
self.assertEqual(len(prompts), 1)
self.assertEqual(prompts[0]["name"], cs.DEFAULT_OUTFIT_PROMPT_NAME)
def test_prompts_drop_invalid_entries(self):
import json
(self.config_dir / "outfit_prompts.json").write_text(
json.dumps([{"name": "", "text": "x"}, {"name": "ok", "text": "y"},
{"name": "bad", "text": 5}, "nope"]), encoding="utf-8")
prompts = cs.load_outfit_prompts()
self.assertEqual(prompts, [{"name": "ok", "text": "y"}])
def test_prompts_tolerate_bom(self):
import json
(self.config_dir / "outfit_prompts.json").write_bytes(
"".encode("utf-8") + json.dumps([{"name": "z", "text": "t"}]).encode("utf-8"))
self.assertEqual(cs.load_outfit_prompts()[0]["name"], "z")
if __name__ == "__main__":
unittest.main()