feat: 完成AI模型清单后端

- 扩展 appconfig 读写 config/ai_models.json

- 支持模型增删改、分类过滤、key 打码展示和 get_model 明文返回

- 增加模型唯一性、类别保底和测试连接错误返回

- 更新任务看板、模块合约、当前状态和进度记录
This commit is contained in:
chengma
2026-06-27 09:10:17 +08:00
parent 8ad1754946
commit 0de7c84163
5 changed files with 282 additions and 11 deletions
+256 -3
View File
@@ -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)}
+1 -1
View File
@@ -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 · 账号管理(④)
+8 -3
View File
@@ -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)。
+7 -4
View File
@@ -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
+10
View File
@@ -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。