import json import logging logger = logging.getLogger(__name__) DEFAULT_CONFIG = { "output_format": "PNG", # PNG or JPG "output_quality": 95, # JPG quality 1-95 "output_dir": "", # empty = use app output/ dir "last_garment_dir": "", "last_print_dir": "", "last_template": "", # name of the last-selected template "last_batch_mode": "full_combo", # BatchMode value of the last-used mode "last_tab": 0, # last-selected workflow tab (0=添加印花, 1=AI 穿搭) "update_source": "", # HTTP(S) manifest source (empty = no update check) "update_user": "", # HTTP Basic Auth user (empty = anonymous) "update_pass": "", # HTTP Basic Auth password (empty = anonymous) # AI 穿搭(docs/11 §11):上次路径与批量设置并入 app_config(密钥不在此,见 ai_models.json) "outfit_excel": "", "outfit_output_dir": "", "outfit_model": "", # last-selected model name "outfit_concurrency": 1, "outfit_request_interval": 2.0, "outfit_task_cooldown": 1.0, "outfit_retry_count": 2, "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" # 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). Seeded into outfit_prompts.json on first run. DEFAULT_OUTFIT_PROMPT = ( "为商品「{title}」生成人物上身实穿效果图:真人模特正面穿着这件衣服," "完整保留款式、版型、颜色与印花图案,自然光、纯色棚拍背景," "电商主图风格,不加文字与促销标签。" ) def load_config(): """ 从 JSON 文件加载应用配置。 返回默认配置与文件配置的合并字典。如果配置文件不存在、损坏或无法读取, 函数不会抛出异常,而是记录日志并返回默认配置。 Returns: dict: 合并后的配置字典 """ from services.file_service import get_config_path config_file = get_config_path(_CONFIG_FILENAME) if not config_file.exists(): logger.info("Config file not found, using defaults: %s", config_file) return dict(DEFAULT_CONFIG) try: with open(str(config_file), encoding="utf-8") as f: data = json.load(f) if not isinstance(data, dict): raise ValueError("Config root is not a JSON object") merged = dict(DEFAULT_CONFIG) merged.update(data) logger.info("Config loaded from %s", config_file) return merged except (json.JSONDecodeError, ValueError) as exc: logger.warning("Config file damaged (%s), using defaults: %s", exc, config_file) return dict(DEFAULT_CONFIG) except OSError as exc: logger.warning("Config file unreadable (%s), using defaults: %s", exc, config_file) return dict(DEFAULT_CONFIG) def save_config(data): """Save config dict to JSON. Logs error on failure, does not raise.""" from services.file_service import get_config_path config_file = get_config_path(_CONFIG_FILENAME) try: config_file.parent.mkdir(parents=True, exist_ok=True) with open(str(config_file), "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) logger.info("Config saved to %s", config_file) except OSError as exc: logger.error("Failed to save config to %s: %s", config_file, exc) def _seed_factory_config_if_missing(filename): """Copy a packaged config template to user config only when missing.""" from shutil import copy2 from services.file_service import get_app_dir, get_config_path target = get_config_path(filename) if target.exists(): return False source = get_app_dir() / "config" / filename if not source.exists(): return False try: target.parent.mkdir(parents=True, exist_ok=True) copy2(str(source), str(target)) logger.info("Seeded factory config from %s to %s", source, target) return True except OSError as exc: logger.warning( "Failed to seed factory config from %s to %s: %s", source, target, exc, ) return False def load_ai_models(): """Load AI model configs from ai_models.json. Returns a list of dicts (each with a ``name`` plus AiModelConfig fields). Missing/corrupt file → empty list (the UI then prompts the admin to add one). Keys live only in ~/.cmbot and are never committed (docs/11 §11). """ from services.file_service import get_config_path _seed_factory_config_if_missing(_AI_MODELS_FILENAME) models_file = get_config_path(_AI_MODELS_FILENAME) if not models_file.exists(): logger.info("AI models file not found: %s", models_file) return [] try: with open(str(models_file), encoding="utf-8-sig") as f: data = json.load(f) except (json.JSONDecodeError, ValueError, OSError) as exc: logger.warning("AI models file unreadable (%s): %s", exc, models_file) return [] models = data.get("models") if isinstance(data, dict) else data if not isinstance(models, list): logger.warning("AI models file has no model list: %s", models_file) return [] return [m for m in models if isinstance(m, dict)] def load_outfit_prompt(): """Return the saved outfit prompt template, or the built-in default.""" from services.file_service import get_config_path prompt_file = get_config_path(_OUTFIT_PROMPT_FILENAME) if not prompt_file.exists(): return DEFAULT_OUTFIT_PROMPT try: with open(str(prompt_file), encoding="utf-8-sig") as f: return f.read() except OSError as exc: logger.warning("Outfit prompt unreadable (%s): %s", exc, prompt_file) return DEFAULT_OUTFIT_PROMPT def save_outfit_prompt(text): """Persist the outfit prompt template (utf-8, no BOM). Does not raise.""" from services.file_service import get_config_path prompt_file = get_config_path(_OUTFIT_PROMPT_FILENAME) try: prompt_file.parent.mkdir(parents=True, exist_ok=True) with open(str(prompt_file), "w", encoding="utf-8") as f: f.write(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)