diff --git a/docs/10-lan-update.md b/docs/10-lan-update.md index 36f5154..97cb2fd 100644 --- a/docs/10-lan-update.md +++ b/docs/10-lan-update.md @@ -108,6 +108,7 @@ 2. 打包态(`sys.frozen`)→ `~/.cmbot`(即 `%USERPROFILE%\.cmbot`)。**不依赖启动器注入环境变量**:即使用户绕过 `Launcher.exe` 直接双击 `app\CMBot.exe`,数据也落在 `~/.cmbot`。 3. 开发态 → 项目根(不污染开发者主目录,保持现状)。 - **首次运行播种**:`~/.cmbot/config/app_config.json`、`templates.json`、`ai_models.json` 不存在时,从程序包内 `app\config\` 拷贝对应出厂默认;缺省再退回内置默认(呼应 `docs/09` 第 6 节)。`ai_models.json` 出厂模板不得包含真实 API key,管理员在用户数据目录中填写。 +- **新增配置的更新兼容**:启动器当前先执行播种,再应用 `staging\app.new`。因此用户通过自更新换到新版时,播种阶段读取的仍可能是旧版 `app\config\`,新增的出厂配置文件(例如 `ai_models.json`)不会在这次启动被复制。且 `Launcher.exe` 本身不参与自更新,旧 Launcher 也可能不知道新增文件名。新增配置文件必须由**主程序运行时兜底补种**:新版 app 启动或加载配置时,如果 `~/.cmbot/config/` 不存在,应从当前新版 `app\config\` 复制一次,仍不得覆盖用户已有文件。 - 写入配置/模板/日志/输出前按需创建多级目录(`parents=True`)。 数据目录分离与 `~/.cmbot` 约定后续需同步 `docs/05-project-architecture.md` 与 `docs/09` 第 5、6 节的目录说明。 diff --git a/docs/11-ai-outfit.md b/docs/11-ai-outfit.md index 403d424..8f80d63 100644 --- a/docs/11-ai-outfit.md +++ b/docs/11-ai-outfit.md @@ -106,6 +106,56 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只 - **取图**:递归遍历响应 JSON 找 base64 / data-url / 图片 URL(再下载),对中转 API 结构差异强兼容。 - **URL 归一化**、`extra_body` 合并、字段校验(缺 `url/model/api_key` 时阻止开始)。 +### 6.1 `ai_models.json` 出厂模板 + +为降低首次部署成本,发布包应在 `app\config\ai_models.json` 内带一份**模型配置模板**。 +用户数据仍以 `~/.cmbot/config/ai_models.json` 为准,程序不会直接把出厂模板当成用户配置使用。 + +播种/补种规则: + +- 首次安装:启动器可从 `app\config\ai_models.json` 播种到 `~/.cmbot/config/ai_models.json`。 +- 自更新:不能只依赖启动器播种。启动器目前先播种旧 `app\config`,再应用新版 `app`;同时 `Launcher.exe` 本身不自更新,旧启动器也可能不知道 `ai_models.json`。因此新版 app 在启动或 `load_ai_models()` 时必须做运行时兜底:如果 `~/.cmbot/config/ai_models.json` 不存在,且当前 `app\config\ai_models.json` 存在,则复制一次。 +- 任何播种/补种都**不得覆盖**用户已有 `~/.cmbot/config/ai_models.json`。 + +模板格式采用当前程序可直接加载的结构: + +```json +{ + "models": [ + { + "name": "GPT Image 2", + "url": "https://api.vectorengine.ai/v1/images/edits", + "model": "gpt-image-2", + "api_key": "", + "api_type": "images_edits", + "timeout_seconds": 0, + "connect_timeout_seconds": 30, + "extra_body": {} + }, + { + "name": "Nano Banana 2", + "url": "https://api.vectorengine.ai/v1/chat/completions", + "model": "gemini-3.1-flash-image-preview", + "api_key": "", + "api_type": "auto", + "timeout_seconds": 0, + "connect_timeout_seconds": 30, + "extra_body": {} + } + ] +} +``` + +规则: + +- `name` 是界面下拉框显示值,也是 `app_config.json` 中 `outfit_model` 的持久化值。 +- `timeout_seconds=0` 表示按分辨率自动取超时(见 §8),`connect_timeout_seconds=30` 只控制连接阶段。 +- 出厂模板**不得提交真实 `api_key`**;管理员在目标机器的 `~/.cmbot/config/ai_models.json` + 中填写真实 key。 +- 旧项目的 `api_config.json` 如为 `{last_selected_model, models:{id:{display_name,...}}}` + 结构,迁移时把 `models` 对象转成列表,并把 `display_name` 映射为 `name`。 +- 当前旧配置里的 `last_selected_model=nano_banana_2` 对应 `outfit_model="Nano Banana 2"`。 + > Python 3.7 注意:旧项目用了 `dict[str, Any]` 等 PEP 585 写法,移植时需 `from __future__ import annotations` 或改用 `typing.Dict`,以兼容 cmbot 的 Python 3.7.9。 ## 7. 提示词 @@ -291,6 +341,7 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只 遵循 cmbot「配置集中、放数据目录、凭据不入库」约定: - **AI 模型与密钥** → `~/.cmbot/config/ai_models.json`(管理员预置或界面填写,**含明文 key、不提交 git**;与更新源凭据同等对待,见 `docs/10` §14)。 +- 发布包可附带 `app\config\ai_models.json` 出厂模板(见 §6.1),用于首次播种;模板中的 `api_key` 必须为空或占位,真实 key 只写入用户数据目录。 - **提示词** → `~/.cmbot/config/outfit_prompt.txt`。 - **批量设置 + 上次 Excel/输出路径** → 并入 `app_config.json`(`config_service` 集中读写,UI 不直接读写配置文件,遵守 `docs/04` 第 6 节 / `docs/05` 4.12)。 - 失败记录 / 日志沿用 `~/.cmbot/logs` 与现有日志服务。 diff --git a/packaging/default_config/ai_models.json b/packaging/default_config/ai_models.json index 3f168ef..7388e32 100644 --- a/packaging/default_config/ai_models.json +++ b/packaging/default_config/ai_models.json @@ -4,7 +4,7 @@ "name": "GPT Image 2", "url": "https://api.vectorengine.ai/v1/images/edits", "model": "gpt-image-2", - "api_key": "sk-JgzW0CWem3ybS6JtmiML2s9IbDD68hxVTrPXnpZnQRbxpC9b", + "api_key": "", "api_type": "images_edits", "timeout_seconds": 0, "connect_timeout_seconds": 30, @@ -14,7 +14,7 @@ "name": "Nano Banana 2", "url": "https://api.vectorengine.ai/v1/chat/completions", "model": "gemini-3.1-flash-image-preview", - "api_key": "sk-7x0SvcyoGsVpWPW9V622y6vsiG6BvaNJLCGKKUj9StnkZbwI", + "api_key": "", "api_type": "auto", "timeout_seconds": 0, "connect_timeout_seconds": 30, diff --git a/src/services/config_service.py b/src/services/config_service.py index dab0d27..65ce82c 100644 --- a/src/services/config_service.py +++ b/src/services/config_service.py @@ -91,6 +91,34 @@ def save_config(data): 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. @@ -99,6 +127,7 @@ def load_ai_models(): 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) diff --git a/tasks.md b/tasks.md index 3a05832..21c2989 100644 --- a/tasks.md +++ b/tasks.md @@ -1161,6 +1161,19 @@ - [x] `tests/test_ai_outfit.py`:扇出/幂等跳过/空目录/部分失败/命名过滤等用例;既有单文件用例保持绿;全套 12 文件 py37 全绿 - [x] 离屏冒烟:临时 Excel 的 C 指向含 3 张图的目录 + mock API,断言 `输出/<目录名>/` 下 3 个 jpg、D=子目录、E=完成、缩略图 3 张 +### 19.13 AI 模型配置模板 — docs/11 §6.1 / docs/10 §5 + +前置阅读:`docs/11-ai-outfit.md`(§6.1、§11)、`docs/10-lan-update.md`(§4、§5)、`src/services/config_service.py`(`load_ai_models`)、`src/launcher.py`(首次播种)。 + +背景:当前 AI 模型配置只能由管理员手动创建 `~/.cmbot/config/ai_models.json`。需要提供出厂模板,使用现有 `api_config.json` 中的两个模型(GPT Image 2 / Nano Banana 2)转换为当前程序可直接读取的 `ai_models.json` 列表结构,但**不得提交真实 API key**。 + +- [x] 新增 `packaging/default_config/ai_models.json`,格式为 `{ "models": [...] }`;`display_name` → `name`;保留 `url/model/api_type/timeout_seconds/connect_timeout_seconds/extra_body`;`api_key` 留空或占位 +- [x] 更新 `packaging/default_config/app_config.json`,新增 `outfit_model: "Nano Banana 2"`,对应旧配置的 `last_selected_model=nano_banana_2` +- [x] 更新 `src/launcher.py` 首次播种白名单,把 `ai_models.json` 与 `app_config.json`、`templates.json` 一起复制到 `~/.cmbot/config`;已有用户文件不得覆盖 +- [x] 补 `tests/test_launcher.py`:首次播种复制 `ai_models.json`,已有 `ai_models.json` 不覆盖 +- [x] 补配置读取验证:`load_ai_models()` 能读取出厂模板的两个模型名,且模板不含真实 key +- [x] 验证:`python -m unittest discover -s tests` 通过;主窗口启动后 AI 模型下拉可显示模板模型(未填 key 时开始生成仍应提示缺 `api_key`) + ### 19.14 货号(B列)改为可选 — docs/11 §4 / §9 前置阅读:`docs/11-ai-outfit.md`(§4、§9、§4.1)、`src/services/excel_service.py`(`load_outfit_tasks`/`read_all_rows` 完整性判定)、`src/core/ai_outfit.py`(`make_outfit_output_path`/`generate_outfit_image` 单文件分支)、`src/app/widgets/ai_outfit_panel.py`(`_fill_sample_combo`) @@ -1172,3 +1185,18 @@ - [x] `ai_outfit_panel.py`:样本下拉标签货号为空时显示「(无货号)」,明细表货号列允许空 - [x] `tests/test_excel_service.py` / `test_ai_outfit.py`:空货号行被 `read_all_rows`/`load_outfit_tasks` 收录;单文件空货号 → 输出按源图名命名;既有用例保持绿 - [x] 离屏冒烟:用「标题填、货号空、C=目录」的表,断言预览下拉有行、生成产出到子目录、E=完成;全套 py37 全绿 + +### 19.15 `ai_models.json` 运行时兜底补种 — docs/11 §6.1 / docs/10 §5 + +前置阅读:`docs/11-ai-outfit.md`(§6.1)、`docs/10-lan-update.md`(§5)、`src/services/config_service.py`(`load_ai_models`)、`src/services/file_service.py`(`get_app_dir`/`get_config_path`)。 + +背景:19.13 已把 `ai_models.json` 加入出厂模板和新启动器播种白名单。但用户通过自更新升级时,启动器播种发生在应用新版 `app` 之前,读取的是旧版 `app\config`;且旧 `Launcher.exe` 不自更新,可能根本不知道 `ai_models.json`。结果新版 `app\config\ai_models.json` 已存在,但 `~/.cmbot/config/ai_models.json` 仍缺失。 + +设计取舍:由主程序运行时兜底补种新增配置文件。`load_ai_models()` 读取用户配置前,如果 `get_config_path("ai_models.json")` 不存在,则尝试从当前程序根 `get_app_dir()/config/ai_models.json` 复制到用户配置目录;已有用户文件永不覆盖。模板 key 为空,管理员仍需在 `~/.cmbot/config/ai_models.json` 填真实 key。 + +- [x] `config_service.py` 增加私有 helper(如 `_seed_factory_config_if_missing(filename)`),只负责「目标不存在时,从当前 `app\config` 复制」 +- [x] `load_ai_models()` 在读取前调用该 helper,确保旧 Launcher/自更新场景也能补出 `~/.cmbot/config/ai_models.json` +- [x] helper 只在源文件存在且目标不存在时复制;复制失败记录日志并继续返回空列表,不影响程序启动 +- [x] 不覆盖用户已有 `ai_models.json`,不合并、不重写真实 key +- [x] 补 `tests/test_config_service.py`:缺用户文件 + 有出厂模板 → 自动复制并加载;已有用户文件 → 不覆盖;源模板不存在 → 返回空且不抛异常 +- [x] 验证:相关单测和全套 `python -m unittest discover -s tests` 通过;模拟旧 Launcher 场景(只放新版 `app\config\ai_models.json`,用户目录缺文件)时 AI 模型下拉能显示模板模型 diff --git a/tests/test_config_service.py b/tests/test_config_service.py index a8d6afb..96c05b3 100644 --- a/tests/test_config_service.py +++ b/tests/test_config_service.py @@ -10,6 +10,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent / "src")) import services.config_service as cs +import services.file_service as fs class TestOutfitConfigHelpers(unittest.TestCase): @@ -19,8 +20,13 @@ class TestOutfitConfigHelpers(unittest.TestCase): os.environ["CMBOT_DATA_DIR"] = str(self.tmp) self.config_dir = self.tmp / "config" self.config_dir.mkdir(parents=True, exist_ok=True) + self.app_dir = self.tmp / "app" + self.factory_config_dir = self.app_dir / "config" + self._orig_get_app_dir = fs.get_app_dir + fs.get_app_dir = lambda: self.app_dir def tearDown(self): + fs.get_app_dir = self._orig_get_app_dir if self._env is None: os.environ.pop("CMBOT_DATA_DIR", None) else: @@ -63,6 +69,40 @@ class TestOutfitConfigHelpers(unittest.TestCase): self.assertEqual([m["name"] for m in models], ["GPT Image 2", "Nano Banana 2"]) self.assertTrue(all(m.get("api_key") == "" for m in models)) + def test_load_ai_models_seeds_missing_user_file_from_factory_template(self): + self.factory_config_dir.mkdir(parents=True, exist_ok=True) + (self.factory_config_dir / "ai_models.json").write_text( + json.dumps({"models": [{"name": "factory", "url": "https://x"}]}), + encoding="utf-8", + ) + + models = cs.load_ai_models() + + self.assertEqual(models[0]["name"], "factory") + user_file = self.config_dir / "ai_models.json" + self.assertTrue(user_file.exists()) + copied = json.loads(user_file.read_text(encoding="utf-8")) + self.assertEqual(copied["models"][0]["name"], "factory") + + def test_load_ai_models_does_not_overwrite_existing_user_file(self): + self.factory_config_dir.mkdir(parents=True, exist_ok=True) + (self.factory_config_dir / "ai_models.json").write_text( + json.dumps({"models": [{"name": "factory"}]}), encoding="utf-8") + (self.config_dir / "ai_models.json").write_text( + json.dumps({"models": [{"name": "user", "api_key": "keep"}]}), + encoding="utf-8", + ) + + models = cs.load_ai_models() + + self.assertEqual(models[0]["name"], "user") + kept = json.loads((self.config_dir / "ai_models.json").read_text(encoding="utf-8")) + self.assertEqual(kept["models"][0]["api_key"], "keep") + + def test_load_ai_models_missing_factory_template_returns_empty(self): + self.assertEqual(cs.load_ai_models(), []) + self.assertFalse((self.config_dir / "ai_models.json").exists()) + # -- outfit_prompt.txt ---------------------------------------------- def test_prompt_default_when_missing(self):