diff --git a/app/appconfig.py b/app/appconfig.py index 5aeca1f..7578376 100644 --- a/app/appconfig.py +++ b/app/appconfig.py @@ -1,16 +1,21 @@ """Application-level configuration for cmshopee. -This module owns `config.json`, which stores local app settings and AI role/ -generation parameters. AI provider definitions and API keys are intentionally -kept out of this file; T-005 will own `config/ai_models.json`. +This module owns `config.json` plus `config/ai_models.json`. `config.json` +stores local app settings and AI role/generation parameters. AI provider +definitions and local plaintext API keys live in ignored `config/ai_models.json`. """ import copy import json import os +import urllib.error +import urllib.request CONFIG_PATH = "config.json" +AI_MODELS_PATH = os.path.join("config", "ai_models.json") +CATEGORIES = {"text", "image"} +API_TYPES = {"chat", "images_edits", "auto"} DEFAULT_CONFIG = { "chrome_path": r"C:\Program Files\Google\Chrome\Application\chrome.exe", @@ -37,6 +42,35 @@ DEFAULT_CONFIG = { }, } +DEFAULT_AI_MODELS_CONFIG = { + "models": [ + { + "name": "GPT-5.5 文本", + "category": "text", + "enabled": True, + "url": "", + "model": "", + "api_key": "", + "api_type": "chat", + "connect_timeout_seconds": 30, + "timeout_seconds": 0, + "extra_body": {}, + }, + { + "name": "Nano Banana 2", + "category": "image", + "enabled": True, + "url": "https://api.vectorengine.ai/v1/chat/completions", + "model": "gemini-3.1-flash-image-preview", + "api_key": "", + "api_type": "auto", + "connect_timeout_seconds": 30, + "timeout_seconds": 0, + "extra_body": {}, + }, + ] +} + SECRET_FIELD_NAMES = {"api_key", "apikey", "key", "token", "password"} @@ -50,6 +84,12 @@ def default_config() -> dict: return copy.deepcopy(DEFAULT_CONFIG) +def default_ai_models_config() -> dict: + """Return a new copy of the default AI model list.""" + + return copy.deepcopy(DEFAULT_AI_MODELS_CONFIG) + + def _deep_merge(defaults, loaded): if not isinstance(defaults, dict): return copy.deepcopy(loaded) if loaded is not None else copy.deepcopy(defaults) @@ -163,3 +203,216 @@ def response_timeout(config=None) -> int: if resolution not in timeouts: raise ConfigError(f"未配置分辨率 {resolution} 的返回超时") return int(timeouts[resolution]) + + +def _normalize_ai_model(model): + if not isinstance(model, dict): + raise ConfigError("AI 模型定义必须是对象") + normalized = { + "name": str(model.get("name", "")).strip(), + "category": str(model.get("category", "")).strip(), + "enabled": bool(model.get("enabled", True)), + "url": str(model.get("url", "")).strip(), + "model": str(model.get("model", "")).strip(), + "api_key": str(model.get("api_key", "")), + "api_type": str(model.get("api_type", "auto")).strip() or "auto", + "connect_timeout_seconds": int(model.get("connect_timeout_seconds", 30) or 30), + "timeout_seconds": int(model.get("timeout_seconds", 0) or 0), + "extra_body": copy.deepcopy(model.get("extra_body", {})), + } + if not normalized["name"]: + raise ConfigError("AI 模型 name 不能为空") + if normalized["category"] not in CATEGORIES: + raise ConfigError("AI 模型 category 必须是 text 或 image") + if normalized["api_type"] not in API_TYPES: + raise ConfigError("AI 模型 api_type 必须是 chat、images_edits 或 auto") + if normalized["connect_timeout_seconds"] <= 0: + raise ConfigError("AI 模型 connect_timeout_seconds 必须大于 0") + if normalized["timeout_seconds"] < 0: + raise ConfigError("AI 模型 timeout_seconds 不能小于 0") + if not isinstance(normalized["extra_body"], dict): + raise ConfigError("AI 模型 extra_body 必须是对象") + return normalized + + +def _normalize_ai_models_config(config): + models = config.get("models") if isinstance(config, dict) else None + if not isinstance(models, list): + raise ConfigError("config/ai_models.json 必须包含 models 列表") + normalized = {"models": [_normalize_ai_model(model) for model in models]} + _assert_unique_model_names(normalized["models"]) + _assert_required_categories(normalized["models"]) + return normalized + + +def _assert_unique_model_names(models): + names = [model["name"] for model in models] + duplicates = sorted({name for name in names if names.count(name) > 1}) + if duplicates: + raise ConfigError(f"AI 模型 name 重复: {', '.join(duplicates)}") + + +def _assert_required_categories(models): + enabled_categories = { + model["category"] for model in models if model.get("enabled", True) + } + missing = sorted(CATEGORIES - enabled_categories) + if missing: + raise ConfigError( + "AI 模型清单至少需要启用一个 text 和一个 image 模型,缺少: " + + ", ".join(missing) + ) + + +def _model_index(models, name): + for index, model in enumerate(models): + if model["name"] == name: + return index + raise ConfigError(f"AI 模型不存在: {name}") + + +def _mask_api_key(api_key): + if not api_key: + return "" + if len(api_key) <= 8: + return "***" + return f"{api_key[:4]}***{api_key[-4:]}" + + +def _public_model(model): + public = copy.deepcopy(model) + public["api_key"] = _mask_api_key(public.get("api_key", "")) + public["api_key_set"] = bool(model.get("api_key")) + return public + + +def save_ai_models_config(config, path=AI_MODELS_PATH) -> dict: + """Persist AI model definitions, including local plaintext API keys.""" + + normalized = _normalize_ai_models_config(config) + directory = os.path.dirname(os.path.abspath(path)) + if directory: + os.makedirs(directory, exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + json.dump(normalized, fh, ensure_ascii=False, indent=2) + fh.write("\n") + return normalized + + +def load_ai_models_config(path=AI_MODELS_PATH) -> dict: + """Load AI model definitions, writing defaults first if missing.""" + + if not os.path.exists(path): + return save_ai_models_config(default_ai_models_config(), path=path) + with open(path, "r", encoding="utf-8") as fh: + try: + loaded = json.load(fh) + except json.JSONDecodeError as exc: + raise ConfigError(f"AI 模型清单不是有效 JSON: {path}") from exc + return _normalize_ai_models_config(loaded) + + +def list_ai_models(category=None, path=AI_MODELS_PATH, reveal_api_key=False): + """Return AI models, optionally filtered by category.""" + + if category is not None and category not in CATEGORIES: + raise ConfigError("category 必须是 text 或 image") + models = load_ai_models_config(path)["models"] + filtered = [ + copy.deepcopy(model) + for model in models + if category is None or model["category"] == category + ] + if reveal_api_key: + return filtered + return [_public_model(model) for model in filtered] + + +def add_ai_model(model, path=AI_MODELS_PATH) -> None: + config = load_ai_models_config(path) + normalized = _normalize_ai_model(model) + if any(item["name"] == normalized["name"] for item in config["models"]): + raise ConfigError(f"AI 模型 name 已存在: {normalized['name']}") + config["models"].append(normalized) + save_ai_models_config(config, path=path) + + +def update_ai_model(model_name, path=AI_MODELS_PATH, **fields) -> None: + if not fields: + return + config = load_ai_models_config(path) + index = _model_index(config["models"], model_name) + updated = copy.deepcopy(config["models"][index]) + updated.update(fields) + normalized = _normalize_ai_model(updated) + if normalized["name"] != model_name and any( + model["name"] == normalized["name"] for model in config["models"] + ): + raise ConfigError(f"AI 模型 name 已存在: {normalized['name']}") + config["models"][index] = normalized + save_ai_models_config(config, path=path) + + +def delete_ai_model(name, path=AI_MODELS_PATH) -> None: + config = load_ai_models_config(path) + index = _model_index(config["models"], name) + remaining = config["models"][:index] + config["models"][index + 1 :] + _assert_required_categories(remaining) + save_ai_models_config({"models": remaining}, path=path) + + +def get_model(name, path=AI_MODELS_PATH) -> dict: + """Return the model definition including api_key. Callers must not log it.""" + + models = load_ai_models_config(path)["models"] + return copy.deepcopy(models[_model_index(models, name)]) + + +def _test_request_payload(model): + if model["api_type"] == "images_edits": + payload = {"model": model["model"], "prompt": "ping"} + payload.update(model.get("extra_body", {})) + return payload + payload = { + "model": model["model"], + "messages": [{"role": "user", "content": "ping"}], + } + payload.update(model.get("extra_body", {})) + return payload + + +def test_ai_model(name, path=AI_MODELS_PATH) -> dict: + """Send a minimal request to the configured model endpoint.""" + + try: + model = get_model(name, path=path) + missing = [ + field + for field in ("url", "model", "api_key") + if not str(model.get(field, "")).strip() + ] + if missing: + return {"ok": False, "error": "模型缺少字段: " + ", ".join(missing)} + data = json.dumps(_test_request_payload(model), ensure_ascii=False).encode( + "utf-8" + ) + request = urllib.request.Request( + model["url"], + data=data, + headers={ + "Authorization": "Bearer " + model["api_key"], + "Content-Type": "application/json", + }, + method="POST", + ) + with urllib.request.urlopen( + request, timeout=int(model["connect_timeout_seconds"]) + ) as response: + response.read(1024) + return {"ok": 200 <= response.status < 300, "status": response.status} + except urllib.error.HTTPError as exc: + return {"ok": False, "status": exc.code, "error": f"HTTP {exc.code}"} + except urllib.error.URLError as exc: + return {"ok": False, "error": str(exc.reason)} + except Exception as exc: + return {"ok": False, "error": str(exc)} diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 1dfba56..2376f07 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -28,7 +28,7 @@ | T-002 | `app/appconfig.py` + `config.json`(含 image_dir、ai 选择/参数段、端口等默认值;不含 AI Key) | T-000 | 读写正常;不存在则写默认;AI Key 留给 `config/ai_models.json`/T-501 | DONE | | T-003 | `app/db.py` + SQLite 建表(batches/accounts/tasks,含 Excel 行定位、状态、时间戳、重试字段) | T-000 | `init_db` 幂等;`connect` 设置 WAL/busy_timeout/foreign_keys;账号/批次/任务/各 set_* 可用;schema 同架构 5.2 | DONE | | T-004 | `.gitignore`:排除 `config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` | T-002, T-003 | 配置、密钥、凭证、业务数据、图片不被提交 | DONE | -| T-005 | AI 模型清单后端:`config/ai_models.json` 读写 + category 过滤 + 测试连接 | T-002 | 本地明文 api_key;UI/API 打码显示;日志脱敏;至少 text/image 各一个;`get_model` 返回调用所需字段 | TODO | +| T-005 | AI 模型清单后端:`config/ai_models.json` 读写 + category 过滤 + 测试连接 | T-002 | 本地明文 api_key;UI/API 打码显示;日志脱敏;至少 text/image 各一个;`get_model` 返回调用所需字段 | DONE | | T-006 | 单元测试基座:`tests/` + appconfig/db/excel/prompts 最小测试 | T-002, T-003 | `python -m unittest discover -s tests` 可跑;不依赖真实 Shopee/AI;临时文件在测试目录清理 | TODO | ## Phase 1 · 账号管理(④) diff --git a/docs/api.md b/docs/api.md index 19f375f..76106f2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -34,17 +34,22 @@ response_timeout(config=None) -> int # = resolution_timeouts[resolutio `config.json` 不保存 AI Key;写入 `api_key` / `*_key` / `token` / `password` 等敏感字段时抛 `ConfigError`。AI Key 留给 `config/ai_models.json`。 -AI 模型清单(`config/ai_models.json`,含本地明文密钥;CRUD 由 ⑤ 设置,T-005 待建): +AI 模型清单(`config/ai_models.json`,含本地明文密钥,已建;UI 由 ⑤ 设置复用): ```python -list_ai_models(category=None) -> list[dict] # category=text/image 过滤;含 connect_timeout_seconds 等 +default_ai_models_config() -> dict +load_ai_models_config(path="config/ai_models.json") -> dict # 不存在则写默认,至少 text/image 各一个 +save_ai_models_config(config, path="config/ai_models.json") -> dict +list_ai_models(category=None) -> list[dict] # category=text/image 过滤;默认 api_key 打码,含 api_key_set add_ai_model(model) -> None # name 唯一校验 update_ai_model(name, **fields) -> None delete_ai_model(name) -> None # 至少各留一个 text+image;删到剩一禁用 -test_ai_model(name) -> dict # 「测试连接」:用 key/url/model 发最小请求 -> {ok, error} +test_ai_model(name) -> dict # 「测试连接」:用 key/url/model 发最小请求 -> {ok, status?, error?} get_model(name) -> dict # 返回模型定义,含 api_key(调用方不得写日志) ``` +`list_ai_models()` 用于 UI/API 展示,默认不返回明文 `api_key`;`get_model()` 用于实际调用 AI,返回明文 `api_key`,调用方不得写日志或导出。`test_ai_model()` 不记录密钥;缺少 `url/model/api_key` 时直接返回 `{ok: False, error: ...}`。 + ## db 模块(`app/db.py`,已建) SQLite 读写,表见 [架构 5.2](04-architecture.md)。 diff --git a/docs/current-state.md b/docs/current-state.md index 0e04a5f..0c00260 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -6,9 +6,9 @@ ## 当前快照 - 日期:2026-06-27 -- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则。 +- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端。 - 技术栈:Python 3.10+,自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(服务商待定),GUI PySide6 5 Tab(已定)。 -- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座;`app/editor.py` 已封装标题/封面/采集/更新按钮能力;`app/appconfig.py` 已实现 `config.json` 默认值、读写、更新、AI 参数与端口配置读取;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数;`app/gui.py` 目前是入口占位,完整 PySide6 主窗口待 T-104。 +- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座;`app/editor.py` 已封装标题/封面/采集/更新按钮能力;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数与端口读取,以及 `config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数;`app/gui.py` 目前是入口占位,完整 PySide6 主窗口待 T-104。 - 测试:当前以 `compileall` + 测试商品手动 CDP 验证为主;`tests/` 与 `python -m unittest discover -s tests` 由 T-006 建立,T-006 完成前不把缺少 `tests/` 视为验证失败。 - 数据:`config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 已由 `.gitignore` 排除;`app/appconfig.py` 首次读取缺失的 `config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。 @@ -51,9 +51,9 @@ 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史记录见 [`../progress.md`](../progress.md)。 -- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(`app/db.py` + SQLite 建表)、T-004(本地数据 gitignore)。 +- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(`app/db.py` + SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)。 - 正在进行:无。 -- 下一个可领取任务:**T-005(AI 模型清单后端)**。 +- 下一个可领取任务:**T-006(单元测试基座)**。 ## 当前可运行内容 @@ -70,6 +70,9 @@ py -3 -c "import os,tempfile; from app import db; d=tempfile.TemporaryDirectory( # gitignore 核心本地数据检查 git check-ignore -v -- config.json config/ai_models.json cmshopee.db chrome_user_data_dir/ images/ +# ai_models 临时清单读写与打码检查 +py -3 -c "import os,tempfile; from app import appconfig; d=tempfile.TemporaryDirectory(dir='.'); p=os.path.join(d.name,'ai_models.json'); print([m['category'] for m in appconfig.list_ai_models(path=p)])" + # 当前入口占位 python main.py py -3 -m app diff --git a/progress.md b/progress.md index 270b390..4113fa6 100644 --- a/progress.md +++ b/progress.md @@ -276,3 +276,13 @@ - 决策:将原来的 `config/` 整目录忽略收窄为 `config/ai_models.json`,与文档安全红线保持一致,避免后续非敏感模板或说明文件被误隐藏。 - 验证:`git check-ignore -v -- config.json config/ai_models.json cmshopee.db chrome_user_data_dir/ images/` 五个路径均命中仓库 `.gitignore`;命令同时提示全局 ignore 文件权限不可读,但不影响仓库规则生效。 - 下一步:按任务看板领取 T-005。 + +## 【2026-06-27】T-005 AI 模型清单后端 + +- 状态:DONE +- 变更:扩展 `app/appconfig.py`,实现 `config/ai_models.json` 默认清单、读写、增删改、category 过滤、展示打码、`get_model` 明文返回、最小请求测试连接;同步 `docs/06-tasks.md`、`docs/current-state.md`、`docs/api.md`。 +- 安全边界:`list_ai_models()` 默认只返回打码 `api_key` 和 `api_key_set`;只有 `get_model()` 返回明文 key 给实际调用层;所有 key 只写入 gitignored 的 `config/ai_models.json`。 +- 约束:模型 `name` 唯一;`category` 只允许 `text/image`;`api_type` 只允许 `chat/images_edits/auto`;保存后必须至少有一个启用的 text 和一个启用的 image 模型。 +- 验证:`py -3 -m compileall app main.py` 通过;临时 `ai_models.json` 默认写入、分类过滤、key 打码、`get_model` 明文 key、重命名/禁用、缺少 url/model/key 的测试连接错误返回均通过;重复 name 与删除最后 image 模型均返回 `ConfigError`。 +- 注意:本轮未使用真实 API Key 发外部网络请求;真实「测试连接」需用户在本地 `config/ai_models.json` 填入有效 url/model/api_key 后由 UI 或函数触发。 +- 下一步:按任务看板领取 T-006。