feat: add cmhub AI backend
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
# ── 凭证 / 业务数据 / 本地产物(绝不提交)──
|
# ── 凭证 / 业务数据 / 本地产物(绝不提交)──
|
||||||
# AI 模型清单 ai_models.json(含密钥)
|
# AI 模型清单 ai_models.json(含密钥)
|
||||||
config/ai_models.json
|
config/ai_models.json
|
||||||
|
# cmhub 网关 API Key(含密钥)
|
||||||
|
config/cmhub.json
|
||||||
# 应用配置(路径/模型选择/参数)
|
# 应用配置(路径/模型选择/参数)
|
||||||
config.json
|
config.json
|
||||||
# SQLite(账号/任务/结果,含密码)
|
# SQLite(账号/任务/结果,含密码)
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
"""AI generation helpers backed by configurable HTTP model endpoints."""
|
"""AI generation helpers backed by configurable HTTP model endpoints."""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import ipaddress
|
||||||
from concurrent.futures import CancelledError, ThreadPoolExecutor, as_completed
|
from concurrent.futures import CancelledError, ThreadPoolExecutor, as_completed
|
||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
|
import socket
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
from . import appconfig, db, diagnostics, image_paths
|
from . import appconfig, db, diagnostics, image_paths
|
||||||
from . import prompts as prompt_module
|
from . import prompts as prompt_module
|
||||||
|
|
||||||
@@ -20,6 +25,18 @@ from . import prompts as prompt_module
|
|||||||
class AIError(RuntimeError):
|
class AIError(RuntimeError):
|
||||||
"""Raised when AI generation cannot complete."""
|
"""Raised when AI generation cannot complete."""
|
||||||
|
|
||||||
|
class CMHubError(AIError):
|
||||||
|
"""Structured cmhub gateway error."""
|
||||||
|
|
||||||
|
def __init__(self, code, message, status=None, retryable=False, retry_after=None):
|
||||||
|
self.code = str(code or "unknown")
|
||||||
|
self.status = status
|
||||||
|
self.retryable = bool(retryable)
|
||||||
|
self.retry_after = retry_after
|
||||||
|
super().__init__("cmhub %s: %s" % (self.code, message))
|
||||||
|
|
||||||
|
|
||||||
|
CMHUB_IMAGE_MAX_BYTES = 20 * 1024 * 1024
|
||||||
|
|
||||||
_RESOLUTION_SIZES = {
|
_RESOLUTION_SIZES = {
|
||||||
"512": (512, 512),
|
"512": (512, 512),
|
||||||
@@ -36,11 +53,23 @@ def gen_title(
|
|||||||
config=None,
|
config=None,
|
||||||
models_path=appconfig.AI_MODELS_PATH,
|
models_path=appconfig.AI_MODELS_PATH,
|
||||||
on_step=None,
|
on_step=None,
|
||||||
|
on_event=None,
|
||||||
|
cmhub_config_path=appconfig.CMHUB_CONFIG_PATH,
|
||||||
):
|
):
|
||||||
"""Generate a new product title from a prompt and the old title."""
|
"""Generate a new product title from a prompt and the old title."""
|
||||||
|
|
||||||
cfg = appconfig.load_config() if config is None else config
|
cfg = appconfig.load_config() if config is None else config
|
||||||
ai_cfg = appconfig.ai_config(cfg)
|
ai_cfg = appconfig.ai_config(cfg)
|
||||||
|
if _ai_backend(ai_cfg) == "cmhub":
|
||||||
|
return _gen_title_cmhub(
|
||||||
|
title_prompt,
|
||||||
|
old_title,
|
||||||
|
retry=retry,
|
||||||
|
config=cfg,
|
||||||
|
cmhub_config_path=cmhub_config_path,
|
||||||
|
on_step=on_step,
|
||||||
|
on_event=on_event,
|
||||||
|
)
|
||||||
_notify_step(on_step, "load_text_model")
|
_notify_step(on_step, "load_text_model")
|
||||||
model = _role_model("text", ai_cfg.get("default_text_model"), models_path)
|
model = _role_model("text", ai_cfg.get("default_text_model"), models_path)
|
||||||
_notify_step(on_step, "title_build_request")
|
_notify_step(on_step, "title_build_request")
|
||||||
@@ -88,6 +117,8 @@ def gen_cover(
|
|||||||
config=None,
|
config=None,
|
||||||
models_path=appconfig.AI_MODELS_PATH,
|
models_path=appconfig.AI_MODELS_PATH,
|
||||||
on_step=None,
|
on_step=None,
|
||||||
|
on_event=None,
|
||||||
|
cmhub_config_path=appconfig.CMHUB_CONFIG_PATH,
|
||||||
):
|
):
|
||||||
"""Generate a new cover image and save it as a JPEG file."""
|
"""Generate a new cover image and save it as a JPEG file."""
|
||||||
|
|
||||||
@@ -100,6 +131,19 @@ def gen_cover(
|
|||||||
|
|
||||||
cfg = appconfig.load_config() if config is None else config
|
cfg = appconfig.load_config() if config is None else config
|
||||||
ai_cfg = appconfig.ai_config(cfg)
|
ai_cfg = appconfig.ai_config(cfg)
|
||||||
|
if _ai_backend(ai_cfg) == "cmhub":
|
||||||
|
return _gen_cover_cmhub(
|
||||||
|
cover_prompt,
|
||||||
|
old_cover_path,
|
||||||
|
out_path,
|
||||||
|
resolution=resolution,
|
||||||
|
jpg_quality=jpg_quality,
|
||||||
|
retry=retry,
|
||||||
|
config=cfg,
|
||||||
|
cmhub_config_path=cmhub_config_path,
|
||||||
|
on_step=on_step,
|
||||||
|
on_event=on_event,
|
||||||
|
)
|
||||||
_notify_step(on_step, "load_image_model")
|
_notify_step(on_step, "load_image_model")
|
||||||
model = _role_model("image", ai_cfg.get("default_image_model"), models_path)
|
model = _role_model("image", ai_cfg.get("default_image_model"), models_path)
|
||||||
resolution = str(resolution or ai_cfg.get("resolution", "1k"))
|
resolution = str(resolution or ai_cfg.get("resolution", "1k"))
|
||||||
@@ -200,6 +244,7 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
|
|||||||
)
|
)
|
||||||
db_path = runtime.get("db_path")
|
db_path = runtime.get("db_path")
|
||||||
models_path = runtime.get("models_path", appconfig.AI_MODELS_PATH)
|
models_path = runtime.get("models_path", appconfig.AI_MODELS_PATH)
|
||||||
|
cmhub_config_path = runtime.get("cmhub_config_path", appconfig.CMHUB_CONFIG_PATH)
|
||||||
image_root = runtime.get("image_dir") or appconfig.image_dir(config)
|
image_root = runtime.get("image_dir") or appconfig.image_dir(config)
|
||||||
account_by_alias = runtime.get("account_by_alias") or {}
|
account_by_alias = runtime.get("account_by_alias") or {}
|
||||||
on_task_update = runtime.get("on_task_update")
|
on_task_update = runtime.get("on_task_update")
|
||||||
@@ -253,6 +298,7 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
|
|||||||
level=event.get("level") or ("warning" if result == "retry" else "info"),
|
level=event.get("level") or ("warning" if result == "retry" else "info"),
|
||||||
attempt=event.get("attempt"),
|
attempt=event.get("attempt"),
|
||||||
attempts=event.get("attempts"),
|
attempts=event.get("attempts"),
|
||||||
|
metadata=event.get("metadata"),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
set_step(task, event)
|
set_step(task, event)
|
||||||
@@ -278,6 +324,8 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
|
|||||||
config=config,
|
config=config,
|
||||||
models_path=models_path,
|
models_path=models_path,
|
||||||
on_step=step_callback(task, "title"),
|
on_step=step_callback(task, "title"),
|
||||||
|
on_event=step_callback(task, "title"),
|
||||||
|
cmhub_config_path=cmhub_config_path,
|
||||||
)
|
)
|
||||||
] = task
|
] = task
|
||||||
for future in as_completed(futures):
|
for future in as_completed(futures):
|
||||||
@@ -366,6 +414,8 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
|
|||||||
config=config,
|
config=config,
|
||||||
models_path=models_path,
|
models_path=models_path,
|
||||||
on_step=step_callback(task, "cover"),
|
on_step=step_callback(task, "cover"),
|
||||||
|
on_event=step_callback(task, "cover"),
|
||||||
|
cmhub_config_path=cmhub_config_path,
|
||||||
)
|
)
|
||||||
] = (task, new_title)
|
] = (task, new_title)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -415,6 +465,467 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
|
|||||||
summary["ok"] = False
|
summary["ok"] = False
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30):
|
||||||
|
"""Fetch cmhub model aliases for settings UI."""
|
||||||
|
|
||||||
|
base_url = str(base_url or "").strip()
|
||||||
|
api_key = str(api_key or "")
|
||||||
|
if not base_url:
|
||||||
|
raise CMHubError("cmhub_not_configured", "请去⑤设置配置 cmhub Base URL")
|
||||||
|
if not api_key:
|
||||||
|
raise CMHubError("cmhub_not_configured", "请去⑤设置配置 cmhub API Key")
|
||||||
|
data = _cmhub_call_with_retry(
|
||||||
|
"GET",
|
||||||
|
appconfig.cmhub_request_url(base_url, "/api/v1/models"),
|
||||||
|
api_key,
|
||||||
|
payload=None,
|
||||||
|
connect_timeout=max(1, int(connect_timeout or 10)),
|
||||||
|
read_timeout=max(1, int(read_timeout or 30)),
|
||||||
|
attempts=1,
|
||||||
|
on_retry=None,
|
||||||
|
)
|
||||||
|
models = data.get("models", [])
|
||||||
|
if not isinstance(models, list):
|
||||||
|
raise CMHubError("bad_response", "cmhub 模型列表格式错误")
|
||||||
|
return [copy.deepcopy(model) for model in models if isinstance(model, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_backend(ai_cfg):
|
||||||
|
backend = str(ai_cfg.get("backend", "direct") or "direct").strip().lower()
|
||||||
|
if backend not in appconfig.AI_BACKENDS:
|
||||||
|
raise AIError("AI backend 必须是 direct 或 cmhub")
|
||||||
|
return backend
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_title_cmhub(
|
||||||
|
title_prompt,
|
||||||
|
old_title,
|
||||||
|
retry,
|
||||||
|
config,
|
||||||
|
cmhub_config_path,
|
||||||
|
on_step=None,
|
||||||
|
on_event=None,
|
||||||
|
):
|
||||||
|
ai_cfg = appconfig.ai_config(config)
|
||||||
|
_notify_step(on_step, "load_text_model")
|
||||||
|
runtime = _cmhub_runtime(config, "title", cmhub_config_path)
|
||||||
|
resolution = _normalize_cmhub_resolution(ai_cfg.get("resolution", "1k"))
|
||||||
|
_notify_step(on_step, "title_build_request")
|
||||||
|
payload = {
|
||||||
|
"prompt": _cmhub_title_prompt(title_prompt, old_title),
|
||||||
|
"model": runtime["alias"],
|
||||||
|
"resolution": resolution,
|
||||||
|
}
|
||||||
|
attempts = _attempt_count(ai_cfg, retry)
|
||||||
|
_notify_step(on_step, "title_request")
|
||||||
|
data = _cmhub_call_with_retry(
|
||||||
|
"POST",
|
||||||
|
appconfig.cmhub_request_url(runtime["base_url"], "/api/v1/generate/title"),
|
||||||
|
runtime["api_key"],
|
||||||
|
payload=payload,
|
||||||
|
connect_timeout=runtime["connect_timeout"],
|
||||||
|
read_timeout=_cmhub_read_timeout(config, ai_cfg.get("resolution", "1k")),
|
||||||
|
attempts=attempts,
|
||||||
|
on_retry=lambda attempt, total_attempts, exc: _notify_cmhub_retry(
|
||||||
|
on_step,
|
||||||
|
"title_request",
|
||||||
|
attempt,
|
||||||
|
total_attempts,
|
||||||
|
exc,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_emit_cmhub_metadata(on_event, data, "title_request")
|
||||||
|
_notify_step(on_step, "title_parse_response")
|
||||||
|
titles = data.get("titles")
|
||||||
|
if not isinstance(titles, list) or not titles:
|
||||||
|
raise AIError("AI 返回为空标题")
|
||||||
|
text = str(titles[0] or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise AIError("AI 返回为空标题")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_cover_cmhub(
|
||||||
|
cover_prompt,
|
||||||
|
old_cover_path,
|
||||||
|
out_path,
|
||||||
|
resolution,
|
||||||
|
jpg_quality,
|
||||||
|
retry,
|
||||||
|
config,
|
||||||
|
cmhub_config_path,
|
||||||
|
on_step=None,
|
||||||
|
on_event=None,
|
||||||
|
):
|
||||||
|
ai_cfg = appconfig.ai_config(config)
|
||||||
|
_notify_step(on_step, "load_image_model")
|
||||||
|
runtime = _cmhub_runtime(config, "image", cmhub_config_path)
|
||||||
|
resolution = str(resolution or ai_cfg.get("resolution", "1k"))
|
||||||
|
quality = _jpg_quality(jpg_quality if jpg_quality is not None else ai_cfg.get("jpg_quality", 90))
|
||||||
|
_notify_step(on_step, "cover_build_request")
|
||||||
|
payload = {
|
||||||
|
"prompt": str(cover_prompt or ""),
|
||||||
|
"model": runtime["alias"],
|
||||||
|
"image_base64": _image_data_url(old_cover_path),
|
||||||
|
"resolution": _normalize_cmhub_resolution(resolution),
|
||||||
|
"aspect_ratio": "1:1",
|
||||||
|
}
|
||||||
|
attempts = _attempt_count(ai_cfg, retry)
|
||||||
|
read_timeout = _cmhub_read_timeout(config, resolution)
|
||||||
|
_notify_step(on_step, "cover_request")
|
||||||
|
data = _cmhub_call_with_retry(
|
||||||
|
"POST",
|
||||||
|
appconfig.cmhub_request_url(runtime["base_url"], "/api/v1/generate/image"),
|
||||||
|
runtime["api_key"],
|
||||||
|
payload=payload,
|
||||||
|
connect_timeout=runtime["connect_timeout"],
|
||||||
|
read_timeout=read_timeout,
|
||||||
|
attempts=attempts,
|
||||||
|
on_retry=lambda attempt, total_attempts, exc: _notify_cmhub_retry(
|
||||||
|
on_step,
|
||||||
|
"cover_request",
|
||||||
|
attempt,
|
||||||
|
total_attempts,
|
||||||
|
exc,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_emit_cmhub_metadata(on_event, data, "cover_request")
|
||||||
|
_notify_step(on_step, "cover_parse_response")
|
||||||
|
image_url = str(data.get("image_url") or "").strip()
|
||||||
|
if not image_url:
|
||||||
|
raise AIError("AI 返回中没有图片数据")
|
||||||
|
image_bytes = _download_cmhub_image(
|
||||||
|
image_url,
|
||||||
|
connect_timeout=runtime["connect_timeout"],
|
||||||
|
read_timeout=read_timeout,
|
||||||
|
)
|
||||||
|
_notify_step(on_step, "cover_save")
|
||||||
|
return _save_jpeg(image_bytes, out_path, resolution, quality)
|
||||||
|
|
||||||
|
|
||||||
|
def _cmhub_runtime(config, operation, cmhub_config_path):
|
||||||
|
hub = appconfig.cmhub_config(config)
|
||||||
|
api_key = appconfig.get_cmhub_api_key(path=cmhub_config_path)
|
||||||
|
alias_key = "title_alias" if operation == "title" else "image_alias"
|
||||||
|
missing = []
|
||||||
|
if not hub.get("base_url"):
|
||||||
|
missing.append("Base URL")
|
||||||
|
if not api_key:
|
||||||
|
missing.append("API Key")
|
||||||
|
if not hub.get(alias_key):
|
||||||
|
missing.append("生文别名" if operation == "title" else "生图别名")
|
||||||
|
if missing:
|
||||||
|
raise CMHubError(
|
||||||
|
"cmhub_not_configured",
|
||||||
|
"请去⑤设置配置 cmhub:缺少 " + "、".join(missing),
|
||||||
|
retryable=False,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"base_url": hub["base_url"].rstrip("/"),
|
||||||
|
"api_key": api_key,
|
||||||
|
"alias": hub[alias_key],
|
||||||
|
"connect_timeout": max(1, int(hub.get("connect_timeout", 10) or 10)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _cmhub_title_prompt(title_prompt, old_title):
|
||||||
|
return "%s\n\n旧标题:\n%s\n\n请只返回新标题,不要解释。" % (
|
||||||
|
str(title_prompt or "").strip(),
|
||||||
|
str(old_title or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_cmhub_resolution(resolution):
|
||||||
|
value = str(resolution or "1k").strip().lower()
|
||||||
|
mapping = {
|
||||||
|
"512": "512",
|
||||||
|
"512x512": "512",
|
||||||
|
"1k": "1K",
|
||||||
|
"1K": "1K",
|
||||||
|
"1024": "1K",
|
||||||
|
"2k": "2K",
|
||||||
|
"2K": "2K",
|
||||||
|
"2048": "2K",
|
||||||
|
"4k": "4K",
|
||||||
|
"4K": "4K",
|
||||||
|
"4096": "4K",
|
||||||
|
}
|
||||||
|
return mapping.get(value, str(resolution or "1K").upper())
|
||||||
|
|
||||||
|
|
||||||
|
def _cmhub_read_timeout(config, resolution):
|
||||||
|
try:
|
||||||
|
timeout = int(appconfig.response_timeout(config))
|
||||||
|
except Exception:
|
||||||
|
timeout = 600
|
||||||
|
resolution_key = str(resolution or "").strip().lower()
|
||||||
|
timeouts = appconfig.ai_config(config).get("resolution_timeouts", {})
|
||||||
|
if resolution_key in timeouts:
|
||||||
|
timeout = int(timeouts[resolution_key])
|
||||||
|
return min(600, max(1, timeout))
|
||||||
|
|
||||||
|
|
||||||
|
def _cmhub_call_with_retry(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
api_key,
|
||||||
|
payload,
|
||||||
|
connect_timeout,
|
||||||
|
read_timeout,
|
||||||
|
attempts,
|
||||||
|
on_retry=None,
|
||||||
|
):
|
||||||
|
attempts = max(1, int(attempts or 1))
|
||||||
|
last_exc = None
|
||||||
|
for index in range(attempts):
|
||||||
|
try:
|
||||||
|
return _cmhub_call_once(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
api_key,
|
||||||
|
payload,
|
||||||
|
connect_timeout=connect_timeout,
|
||||||
|
read_timeout=read_timeout,
|
||||||
|
)
|
||||||
|
except CMHubError as exc:
|
||||||
|
last_exc = exc
|
||||||
|
if not exc.retryable or index + 1 >= attempts:
|
||||||
|
raise
|
||||||
|
if on_retry is not None:
|
||||||
|
try:
|
||||||
|
on_retry(index + 1, attempts, exc)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(_cmhub_retry_delay(exc, index))
|
||||||
|
raise last_exc
|
||||||
|
|
||||||
|
|
||||||
|
def _cmhub_call_once(method, url, api_key, payload, connect_timeout, read_timeout):
|
||||||
|
headers = {
|
||||||
|
"Authorization": "Bearer " + str(api_key),
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
request_kwargs = {
|
||||||
|
"headers": headers,
|
||||||
|
"timeout": (max(1, int(connect_timeout)), max(1, int(read_timeout))),
|
||||||
|
}
|
||||||
|
if str(method).upper() != "GET":
|
||||||
|
request_kwargs["json"] = payload or {}
|
||||||
|
try:
|
||||||
|
response = requests.request(str(method).upper(), url, **request_kwargs)
|
||||||
|
except requests.exceptions.ConnectTimeout as exc:
|
||||||
|
raise CMHubError("connect_timeout", "连接 cmhub 超时", retryable=True) from exc
|
||||||
|
except requests.exceptions.ReadTimeout as exc:
|
||||||
|
raise CMHubError("read_timeout", "等待 cmhub 返回超时", retryable=False) from exc
|
||||||
|
except requests.exceptions.RequestException as exc:
|
||||||
|
raise CMHubError(
|
||||||
|
"network_error",
|
||||||
|
_redact_cmhub(str(exc), api_key),
|
||||||
|
retryable=False,
|
||||||
|
) from exc
|
||||||
|
return _cmhub_response_json(response, api_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _cmhub_response_json(response, api_key):
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError:
|
||||||
|
data = {}
|
||||||
|
status = getattr(response, "status_code", None)
|
||||||
|
if status and status >= 400:
|
||||||
|
raise _cmhub_error_from_response(data, response, api_key)
|
||||||
|
if isinstance(data, dict) and isinstance(data.get("error"), dict):
|
||||||
|
raise _cmhub_error_from_response(data, response, api_key)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise CMHubError("bad_response", "cmhub 返回格式错误", status=status)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _cmhub_error_from_response(data, response, api_key):
|
||||||
|
status = getattr(response, "status_code", None)
|
||||||
|
error = data.get("error") if isinstance(data, dict) else None
|
||||||
|
if not isinstance(error, dict):
|
||||||
|
error = {}
|
||||||
|
code = str(error.get("code") or _cmhub_code_for_status(status))
|
||||||
|
raw_message = (error.get("message") or data.get("message")) if isinstance(data, dict) else ""
|
||||||
|
if not raw_message:
|
||||||
|
raw_message = getattr(response, "text", "")[:500]
|
||||||
|
message = _cmhub_user_message(code, _redact_cmhub(raw_message or code, api_key))
|
||||||
|
retry_after = _parse_retry_after(getattr(response, "headers", {}).get("Retry-After"))
|
||||||
|
return CMHubError(
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
status=status,
|
||||||
|
retryable=_cmhub_retryable(code),
|
||||||
|
retry_after=retry_after,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cmhub_code_for_status(status):
|
||||||
|
return {
|
||||||
|
400: "bad_request",
|
||||||
|
401: "unauthorized",
|
||||||
|
402: "insufficient_points",
|
||||||
|
403: "account_disabled",
|
||||||
|
429: "rate_limited",
|
||||||
|
502: "upstream_error",
|
||||||
|
}.get(status, "unknown")
|
||||||
|
|
||||||
|
|
||||||
|
def _cmhub_retryable(code):
|
||||||
|
return str(code) in {"upstream_error", "rate_limited", "connect_timeout"}
|
||||||
|
|
||||||
|
|
||||||
|
def _cmhub_user_message(code, message):
|
||||||
|
defaults = {
|
||||||
|
"insufficient_points": "点数不足,请先充值",
|
||||||
|
"unauthorized": "cmhub API Key 无效,请去⑤设置重填",
|
||||||
|
"account_disabled": "cmhub 账号已禁用,请去网页端处理",
|
||||||
|
"bad_request": "cmhub 请求参数错误",
|
||||||
|
"model_not_allowed": "cmhub 模型别名无权限",
|
||||||
|
"no_pricing_rule": "cmhub 模型别名未配置价格",
|
||||||
|
"content_blocked": "cmhub 内容安全策略拒绝本次生成",
|
||||||
|
"upstream_error": "cmhub 上游生成失败,请稍后重试",
|
||||||
|
"rate_limited": "cmhub 请求过于频繁,请稍后重试",
|
||||||
|
}
|
||||||
|
default = defaults.get(str(code))
|
||||||
|
if default and message and str(message) not in default:
|
||||||
|
return "%s:%s" % (default, message)
|
||||||
|
return default or str(message or code)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_retry_after(value):
|
||||||
|
try:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
return max(0.0, float(value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cmhub_retry_delay(exc, index):
|
||||||
|
if exc.retry_after is not None:
|
||||||
|
return min(2.0, max(0.1, float(exc.retry_after)))
|
||||||
|
return min(2.0, 0.4 * (index + 1))
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_cmhub_retry(callback, step, attempt, attempts, exc):
|
||||||
|
if callback is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
callback(
|
||||||
|
{
|
||||||
|
"step": step,
|
||||||
|
"result": "retry",
|
||||||
|
"attempt": attempt,
|
||||||
|
"attempts": attempts,
|
||||||
|
"detail": str(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_cmhub_metadata(callback, data, step):
|
||||||
|
if callback is None or not isinstance(data, dict):
|
||||||
|
return
|
||||||
|
metadata = {
|
||||||
|
key: data.get(key)
|
||||||
|
for key in ("alias", "model_used", "points_cost", "points_balance", "call_id")
|
||||||
|
if data.get(key) is not None
|
||||||
|
}
|
||||||
|
if not metadata:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
callback(
|
||||||
|
{
|
||||||
|
"step": step,
|
||||||
|
"result": "meta",
|
||||||
|
"level": "info",
|
||||||
|
"metadata": metadata,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _download_cmhub_image(url, connect_timeout, read_timeout, max_bytes=CMHUB_IMAGE_MAX_BYTES):
|
||||||
|
_assert_public_http_url(url)
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
url,
|
||||||
|
stream=True,
|
||||||
|
timeout=(max(1, int(connect_timeout)), max(1, int(read_timeout))),
|
||||||
|
)
|
||||||
|
except requests.exceptions.RequestException as exc:
|
||||||
|
raise AIError("下载 cmhub 图片失败: %s" % exc) from exc
|
||||||
|
status = getattr(response, "status_code", 200)
|
||||||
|
if status >= 400:
|
||||||
|
raise AIError("下载 cmhub 图片失败: HTTP %s" % status)
|
||||||
|
chunks = []
|
||||||
|
total = 0
|
||||||
|
iterator = response.iter_content(chunk_size=65536) if hasattr(response, "iter_content") else [response.content]
|
||||||
|
for chunk in iterator:
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
total += len(chunk)
|
||||||
|
if total > max_bytes:
|
||||||
|
raise AIError("下载 cmhub 图片失败: 图片超过大小上限")
|
||||||
|
chunks.append(chunk)
|
||||||
|
return b"".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_public_http_url(url):
|
||||||
|
parts = urllib.parse.urlsplit(str(url or ""))
|
||||||
|
if parts.scheme not in {"http", "https"}:
|
||||||
|
raise AIError("cmhub 图片地址只允许 http/https")
|
||||||
|
host = parts.hostname
|
||||||
|
if not host:
|
||||||
|
raise AIError("cmhub 图片地址缺少域名")
|
||||||
|
if _is_local_hostname(host):
|
||||||
|
raise AIError("cmhub 图片地址不能指向本机或内网")
|
||||||
|
try:
|
||||||
|
_assert_public_ip(host)
|
||||||
|
return
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
addresses = socket.getaddrinfo(
|
||||||
|
host,
|
||||||
|
parts.port or (443 if parts.scheme == "https" else 80),
|
||||||
|
type=socket.SOCK_STREAM,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
raise AIError("cmhub 图片地址无法解析: %s" % exc) from exc
|
||||||
|
if not addresses:
|
||||||
|
raise AIError("cmhub 图片地址无法解析")
|
||||||
|
for address in addresses:
|
||||||
|
ip_text = address[4][0]
|
||||||
|
_assert_public_ip(ip_text)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_local_hostname(host):
|
||||||
|
lowered = str(host or "").strip().lower().rstrip(".")
|
||||||
|
return lowered in {"localhost"} or lowered.endswith(".localhost") or lowered.endswith(".local")
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_public_ip(value):
|
||||||
|
ip = ipaddress.ip_address(value)
|
||||||
|
if (
|
||||||
|
ip.is_private
|
||||||
|
or ip.is_loopback
|
||||||
|
or ip.is_link_local
|
||||||
|
or ip.is_multicast
|
||||||
|
or ip.is_reserved
|
||||||
|
or ip.is_unspecified
|
||||||
|
):
|
||||||
|
raise AIError("cmhub 图片地址不能指向本机或内网")
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_cmhub(text, api_key):
|
||||||
|
return appconfig.redact_secrets(text, [api_key])
|
||||||
|
|
||||||
def _role_model(category, name, models_path):
|
def _role_model(category, name, models_path):
|
||||||
if not name:
|
if not name:
|
||||||
raise AIError("未配置默认 %s 模型" % category)
|
raise AIError("未配置默认 %s 模型" % category)
|
||||||
@@ -450,6 +961,8 @@ def _runtime_config(runtime):
|
|||||||
"resolution",
|
"resolution",
|
||||||
"generate_cover",
|
"generate_cover",
|
||||||
"resolution_timeouts",
|
"resolution_timeouts",
|
||||||
|
"backend",
|
||||||
|
"cmhub",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ai_updates:
|
if ai_updates:
|
||||||
@@ -510,6 +1023,7 @@ def _emit_generation_event(
|
|||||||
level="info",
|
level="info",
|
||||||
attempt=None,
|
attempt=None,
|
||||||
attempts=None,
|
attempts=None,
|
||||||
|
metadata=None,
|
||||||
):
|
):
|
||||||
if callback is None:
|
if callback is None:
|
||||||
return
|
return
|
||||||
@@ -526,6 +1040,8 @@ def _emit_generation_event(
|
|||||||
payload["attempt"] = attempt
|
payload["attempt"] = attempt
|
||||||
if attempts is not None:
|
if attempts is not None:
|
||||||
payload["attempts"] = attempts
|
payload["attempts"] = attempts
|
||||||
|
if metadata is not None:
|
||||||
|
payload["metadata"] = appconfig.sanitize_for_log(metadata)
|
||||||
try:
|
try:
|
||||||
callback(payload)
|
callback(payload)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import urllib.request
|
|||||||
|
|
||||||
CONFIG_PATH = "config.json"
|
CONFIG_PATH = "config.json"
|
||||||
AI_MODELS_PATH = os.path.join("config", "ai_models.json")
|
AI_MODELS_PATH = os.path.join("config", "ai_models.json")
|
||||||
|
CMHUB_CONFIG_PATH = os.path.join("config", "cmhub.json")
|
||||||
CATEGORIES = {"text", "image"}
|
CATEGORIES = {"text", "image"}
|
||||||
API_TYPES = {"chat", "images_edits", "auto"}
|
API_TYPES = {"chat", "images_edits", "auto"}
|
||||||
|
AI_BACKENDS = {"direct", "cmhub"}
|
||||||
|
|
||||||
DEFAULT_CONFIG = {
|
DEFAULT_CONFIG = {
|
||||||
"chrome_path": r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
"chrome_path": r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
||||||
@@ -29,6 +31,14 @@ DEFAULT_CONFIG = {
|
|||||||
"default_text_model": "GPT-5.5 文本",
|
"default_text_model": "GPT-5.5 文本",
|
||||||
"default_image_model": "Nano Banana 2",
|
"default_image_model": "Nano Banana 2",
|
||||||
"generate_cover": False,
|
"generate_cover": False,
|
||||||
|
"backend": "direct",
|
||||||
|
"cmhub": {
|
||||||
|
"base_url": "",
|
||||||
|
"title_alias": "",
|
||||||
|
"image_alias": "",
|
||||||
|
"connect_timeout": 10,
|
||||||
|
"check_balance_before_batch": False,
|
||||||
|
},
|
||||||
"title_concurrency": 4,
|
"title_concurrency": 4,
|
||||||
"image_concurrency": 4,
|
"image_concurrency": 4,
|
||||||
"retry": 2,
|
"retry": 2,
|
||||||
@@ -82,6 +92,10 @@ DEFAULT_AI_MODELS_CONFIG = {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_CMHUB_CONFIG = {
|
||||||
|
"api_key": "",
|
||||||
|
}
|
||||||
SECRET_FIELD_NAMES = {"api_key", "apikey", "key", "token", "password"}
|
SECRET_FIELD_NAMES = {"api_key", "apikey", "key", "token", "password"}
|
||||||
|
|
||||||
|
|
||||||
@@ -183,6 +197,52 @@ def redact_secrets(text, secret_values=None) -> str:
|
|||||||
return redacted
|
return redacted
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def default_cmhub_config() -> dict:
|
||||||
|
"""Return a new copy of the default cmhub key config."""
|
||||||
|
|
||||||
|
return copy.deepcopy(DEFAULT_CMHUB_CONFIG)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_cmhub_config(config):
|
||||||
|
if config is None:
|
||||||
|
config = {}
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
raise ConfigError("config/cmhub.json 必须是对象")
|
||||||
|
return {"api_key": str(config.get("api_key", "") or "")}
|
||||||
|
|
||||||
|
|
||||||
|
def load_cmhub_config(path=CMHUB_CONFIG_PATH) -> dict:
|
||||||
|
"""Load cmhub API key config. Missing file means key is not configured."""
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return default_cmhub_config()
|
||||||
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
|
try:
|
||||||
|
loaded = json.load(fh)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ConfigError(f"cmhub 配置不是有效 JSON: {path}") from exc
|
||||||
|
return _normalize_cmhub_config(loaded)
|
||||||
|
|
||||||
|
|
||||||
|
def save_cmhub_config(config, path=CMHUB_CONFIG_PATH) -> dict:
|
||||||
|
"""Persist cmhub API key config, including the local plaintext key."""
|
||||||
|
|
||||||
|
normalized = _normalize_cmhub_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 get_cmhub_api_key(path=CMHUB_CONFIG_PATH, masked=False) -> str:
|
||||||
|
key = load_cmhub_config(path).get("api_key", "")
|
||||||
|
return mask_secret(key) if masked else key
|
||||||
|
|
||||||
|
|
||||||
def save_config(config, path=CONFIG_PATH) -> dict:
|
def save_config(config, path=CONFIG_PATH) -> dict:
|
||||||
"""Persist config to JSON and return the normalized config."""
|
"""Persist config to JSON and return the normalized config."""
|
||||||
|
|
||||||
@@ -258,6 +318,39 @@ def ai_config(config=None) -> dict:
|
|||||||
return copy.deepcopy(_config_or_load(config).get("ai", DEFAULT_CONFIG["ai"]))
|
return copy.deepcopy(_config_or_load(config).get("ai", DEFAULT_CONFIG["ai"]))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def ai_backend(config=None) -> str:
|
||||||
|
ai = ai_config(config)
|
||||||
|
backend = str(ai.get("backend", "direct") or "direct").strip().lower()
|
||||||
|
if backend not in AI_BACKENDS:
|
||||||
|
raise ConfigError("AI backend 必须是 direct 或 cmhub")
|
||||||
|
return backend
|
||||||
|
|
||||||
|
|
||||||
|
def cmhub_config(config=None) -> dict:
|
||||||
|
ai = ai_config(config)
|
||||||
|
value = ai.get("cmhub", {})
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ConfigError("ai.cmhub 必须是对象")
|
||||||
|
merged = _deep_merge(DEFAULT_CONFIG["ai"]["cmhub"], value)
|
||||||
|
merged["base_url"] = str(merged.get("base_url", "") or "").strip()
|
||||||
|
merged["title_alias"] = str(merged.get("title_alias", "") or "").strip()
|
||||||
|
merged["image_alias"] = str(merged.get("image_alias", "") or "").strip()
|
||||||
|
merged["connect_timeout"] = int(merged.get("connect_timeout", 10) or 10)
|
||||||
|
merged["check_balance_before_batch"] = bool(merged.get("check_balance_before_batch", False))
|
||||||
|
if merged["connect_timeout"] <= 0:
|
||||||
|
raise ConfigError("ai.cmhub.connect_timeout 必须大于 0")
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def cmhub_request_url(base_url, endpoint) -> str:
|
||||||
|
base = str(base_url or "").strip().rstrip("/")
|
||||||
|
path = "/" + str(endpoint or "").strip().lstrip("/")
|
||||||
|
if not base:
|
||||||
|
return path
|
||||||
|
return base + path
|
||||||
|
|
||||||
|
|
||||||
def response_timeout(config=None) -> int:
|
def response_timeout(config=None) -> int:
|
||||||
ai = ai_config(config)
|
ai = ai_config(config)
|
||||||
resolution = str(ai.get("resolution", DEFAULT_CONFIG["ai"]["resolution"]))
|
resolution = str(ai.get("resolution", DEFAULT_CONFIG["ai"]["resolution"]))
|
||||||
|
|||||||
@@ -17,9 +17,9 @@
|
|||||||
| 应用配置 | `config.json`(JSON,stdlib) | 已定 | 少量应用级设置:Chrome 路径、目录根、端口、DB 路径等 |
|
| 应用配置 | `config.json`(JSON,stdlib) | 已定 | 少量应用级设置:Chrome 路径、目录根、端口、DB 路径等 |
|
||||||
| 业务数据 | SQLite(stdlib `sqlite3`,`cmshopee.db`) | 已定 | 账号、任务、结果:成行增长、要查询/统计/导出 |
|
| 业务数据 | SQLite(stdlib `sqlite3`,`cmshopee.db`) | 已定 | 账号、任务、结果:成行增长、要查询/统计/导出 |
|
||||||
| Excel 读写 | `openpyxl` | 已定 | 导入任务、回写结果;stdlib 读不了 .xlsx |
|
| Excel 读写 | `openpyxl` | 已定 | 导入任务、回写结果;stdlib 读不了 .xlsx |
|
||||||
| AI 模型注册 | `config/ai_models.json` 多模型清单(HTTP 调用) | 已定(结构) | 每模型 name/category(text/image)/url/model/key/api_type/连接超时;⑤ 设置可增删改+测试连接;Key 本地明文保存,保存/变更时提示 |
|
| AI 模型注册 | `config/ai_models.json` direct 模型清单 + `config/cmhub.json` 网关 Key | 已接入(后端) | direct 模式保留每模型 name/category/url/model/key/api_type;cmhub 模式用 `config.json` 的 Base URL/别名 + `config/cmhub.json` 单 Key;⑤ cmhub 面板待 T-527 |
|
||||||
| AI 文本生成 | `app/ai.py` 读取 `default_text_model`(category=text),通用 chat JSON HTTP | 已接入 | 提示词+旧标题→新标题;失败重试,错误脱敏 |
|
| AI 文本生成 | `app/ai.py` 支持 direct chat JSON 与 cmhub `POST /api/v1/generate/title` | 已接入 | 提示词+旧标题→新标题;返回值不变;cmhub 计费 metadata 通过事件回调上报 |
|
||||||
| AI 图像生成 | `app/ai.py` 读取 `default_image_model`(category=image),支持 chat 多模态 JSON / images_edits multipart | 已接入 | 提示词+旧封面→新封面;分辨率 512/1k/2k/4k,jpg_quality 存盘,返回超时随分辨率 |
|
| AI 图像生成 | `app/ai.py` 支持 direct chat/images_edits 与 cmhub `POST /api/v1/generate/image` | 已接入 | 提示词+旧封面→新封面;cmhub 拿 `image_url` 后安全下载并转本地 JPEG;生图读超时不自动重发 |
|
||||||
| 并发 | 标准库 `concurrent.futures.ThreadPoolExecutor` | 已定 | 标题/图片分别按并发数并行;③ 可按账号并行更新;可停止、可重试 |
|
| 并发 | 标准库 `concurrent.futures.ThreadPoolExecutor` | 已定 | 标题/图片分别按并发数并行;③ 可按账号并行更新;可停止、可重试 |
|
||||||
| 运行日志 | SQLite `run_logs` / `run_log_events` | 已定 | ③ dry-run 与真实更新都留痕;结构化内容走脱敏 |
|
| 运行日志 | SQLite `run_logs` / `run_log_events` | 已定 | ③ dry-run 与真实更新都留痕;结构化内容走脱敏 |
|
||||||
| 图片处理 | `requests`(下载)+ `Pillow`(按分辨率/jpg质量存盘) | 部分待定 | 下载旧封面;新封面按 resolution 生成、jpg_quality 存盘 |
|
| 图片处理 | `requests`(下载)+ `Pillow`(按分辨率/jpg质量存盘) | 部分待定 | 下载旧封面;新封面按 resolution 生成、jpg_quality 存盘 |
|
||||||
@@ -34,9 +34,9 @@
|
|||||||
- **Excel 用 openpyxl**:运营用真实 .xlsx;stdlib 无法读写 xlsx,引入一个轻依赖比改用 CSV 更贴合用户习惯。
|
- **Excel 用 openpyxl**:运营用真实 .xlsx;stdlib 无法读写 xlsx,引入一个轻依赖比改用 CSV 更贴合用户习惯。
|
||||||
- **多账号隔离用独立 user-data-dir,不用 Chrome profile**:profile 共享同一 user-data-dir/进程/调试端口,无法每账号独立 CDP 与并行;独立 user-data-dir 才契合自动化。详见 [架构 3.0](04-architecture.md)。
|
- **多账号隔离用独立 user-data-dir,不用 Chrome profile**:profile 共享同一 user-data-dir/进程/调试端口,无法每账号独立 CDP 与并行;独立 user-data-dir 才契合自动化。详见 [架构 3.0](04-architecture.md)。
|
||||||
- **快捷方式生成用 PowerShell(无额外依赖)**:用 `WScript.Shell.CreateShortcut` 生成 `.lnk`,不引入 `pywin32` 等依赖。
|
- **快捷方式生成用 PowerShell(无额外依赖)**:用 `WScript.Shell.CreateShortcut` 生成 `.lnk`,不引入 `pywin32` 等依赖。
|
||||||
- **AI 服务商不写死在代码里**:T-301 已采用 `config/ai_models.json` 的通用 HTTP 接入,当前支持 OpenAI-compatible chat JSON 与 images_edits multipart;具体服务商/模型/Key 由⑤设置维护。
|
- **AI 服务商不写死在代码里**:T-301 已采用 `config/ai_models.json` 的 direct 通用 HTTP 接入,支持 OpenAI-compatible chat JSON 与 images_edits multipart;T-526 已新增 `backend=cmhub` 网关后端,cmhub 模式使用 Base URL + 生文/生图别名 + `config/cmhub.json` 单 Key,直连模型清单保留用于回退。
|
||||||
- **AI 模型 category 是硬约束**:`config/ai_models.json` 每个模型必须有 `category=text` 或 `category=image`;启动时报 “AI 模型 category 必须是 text 或 image” 时,按 [常见问题排查](troubleshooting.md) 修复本地配置,不删除或提交含 Key 的配置文件。
|
- **AI 模型 category 是硬约束**:`config/ai_models.json` 每个模型必须有 `category=text` 或 `category=image`;启动时报 “AI 模型 category 必须是 text 或 image” 时,按 [常见问题排查](troubleshooting.md) 修复本地配置,不删除或提交含 Key 的配置文件。
|
||||||
- **敏感信息不加密但强提示与脱敏**:密码与 AI Key 只在本机 SQLite / `config/ai_models.json` 明文保存;保存/变更时弹窗提示,UI 打码,日志/导出必须脱敏,相关本地文件必须 gitignore。
|
- **敏感信息不加密但强提示与脱敏**:密码与 AI Key 只在本机 SQLite / `config/ai_models.json` / `config/cmhub.json` 明文保存;保存/变更时弹窗提示,UI 打码,日志/导出必须脱敏,相关本地文件必须 gitignore。
|
||||||
- **AI 产出无逐条审核**:生成的新标题/新封面经 ③ 批量确认后提交线上;无常驻提交开关,本地留档 + 回写 Excel 供追溯。
|
- **AI 产出无逐条审核**:生成的新标题/新封面经 ③ 批量确认后提交线上;无常驻提交开关,本地留档 + 回写 Excel 供追溯。
|
||||||
- **T-504 更新执行增强**:③ 支持 dry-run 预览、运行日志和按账号并行;默认 dry-run 关闭、并行关闭,不引入新依赖。
|
- **T-504 更新执行增强**:③ 支持 dry-run 预览、运行日志和按账号并行;默认 dry-run 关闭、并行关闭,不引入新依赖。
|
||||||
- **不引入数据库(指外部 DB)**:用 stdlib SQLite 足够;不引入 Postgres/MySQL 等。
|
- **不引入数据库(指外部 DB)**:用 stdlib SQLite 足够;不引入 Postgres/MySQL 等。
|
||||||
|
|||||||
+34
-12
@@ -27,7 +27,7 @@ GUI(PySide6 QTabWidget,5 Tab)
|
|||||||
└── ai 文本生成(提示词+旧标题→新标题)/ 图像生成(提示词+旧封面→新封面)
|
└── ai 文本生成(提示词+旧标题→新标题)/ 图像生成(提示词+旧封面→新封面)
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
Google Chrome(每账号独立 --user-data-dir + --remote-debugging-port) + AI 服务(外部)
|
Google Chrome(每账号独立 --user-data-dir + --remote-debugging-port) + AI 服务(direct 外部模型或 cmhub 网关)
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
Shopee 卖家中心页面 / 本地图片目录
|
Shopee 卖家中心页面 / 本地图片目录
|
||||||
@@ -38,7 +38,7 @@ Shopee 卖家中心页面 / 本地图片目录
|
|||||||
- GUI 入口:根目录 `main.py` 调用 `app/gui/` 包(PySide6 + `QMainWindow` + `QTabWidget`,5 Tab);包入口 `app/gui/__init__.py` 提供 `main()` 并兼容 `from app import gui` / `from app.gui import MainWindow`;也支持 `python -m app`。
|
- GUI 入口:根目录 `main.py` 调用 `app/gui/` 包(PySide6 + `QMainWindow` + `QTabWidget`,5 Tab);包入口 `app/gui/__init__.py` 提供 `main()` 并兼容 `from app import gui` / `from app.gui import MainWindow`;也支持 `python -m app`。
|
||||||
- 核心模块统一放在正式代码包 `app/`:`appconfig.py`、`db.py`、`excel.py`、`config.py`、`accounts.py`、`chrome.py`、`editor.py`、`workers.py`、`ai.py`、`prompts.py`;CDP 底座迁入 `app/cdp.py`(当前根目录 `cdp.py` 为已验证来源)。
|
- 核心模块统一放在正式代码包 `app/`:`appconfig.py`、`db.py`、`excel.py`、`config.py`、`accounts.py`、`chrome.py`、`editor.py`、`workers.py`、`ai.py`、`prompts.py`;CDP 底座迁入 `app/cdp.py`(当前根目录 `cdp.py` 为已验证来源)。
|
||||||
- 已验证脚本(重构进模块):`prototypes/demo.py`、`prototypes/set_title.py`、`prototypes/set_cover.py`、`prototypes/get_title.py`、`prototypes/cookies.py`、`prototypes/inspect_images.py`、`prototypes/grab.py`。
|
- 已验证脚本(重构进模块):`prototypes/demo.py`、`prototypes/set_title.py`、`prototypes/set_cover.py`、`prototypes/get_title.py`、`prototypes/cookies.py`、`prototypes/inspect_images.py`、`prototypes/grab.py`。
|
||||||
- 外部依赖:本机 Google Chrome;Shopee;AI 服务(文本+图像,服务商/模型由 `config/ai_models.json` 配置);`openpyxl`。
|
- 外部依赖:本机 Google Chrome;Shopee;AI 服务(文本+图像;direct 模式由 `config/ai_models.json` 配置,cmhub 模式由 `config.json` 的 `ai.cmhub` + `config/cmhub.json` 配置);`openpyxl`。
|
||||||
|
|
||||||
## 二、流水线(核心)
|
## 二、流水线(核心)
|
||||||
|
|
||||||
@@ -71,12 +71,13 @@ imported → collected → generated → applied
|
|||||||
- `chrome`:拼接启动命令、启动、探测端口、(可选)生成快捷方式。
|
- `chrome`:拼接启动命令、启动、探测端口、(可选)生成快捷方式。
|
||||||
- `cdp`:连接调试端口、找/开 tab、执行 JS、拖拽、注入文件。
|
- `cdp`:连接调试端口、找/开 tab、执行 JS、拖拽、注入文件。
|
||||||
- `editor`:登录检测、**采集**(读旧标题、下载旧封面)、改标题、换封面、点更新。
|
- `editor`:登录检测、**采集**(读旧标题、下载旧封面)、改标题、换封面、点更新。
|
||||||
- `ai`:`gen_title(prompt, old_title)`、`gen_cover(prompt, old_cover_path)`(外部 AI;模型清单配置;通用 HTTP)。
|
- `ai`:`gen_title(prompt, old_title)`、`gen_cover(prompt, old_cover_path)`(外部 AI;支持 `direct` 模型清单和 `cmhub` 网关 backend;返回值保持标题字符串/本地 JPEG 路径)。
|
||||||
|
|
||||||
**存储(同一事实只存一处)**
|
**存储(同一事实只存一处)**
|
||||||
|
|
||||||
- 应用配置(模型选择、生成参数、目录、Chrome 路径)→ `config.json`。
|
- 应用配置(模型选择、生成参数、目录、Chrome 路径)→ `config.json`。
|
||||||
- AI 模型清单(url/模型/密钥/类型/连接超时)→ `config/ai_models.json`(API Key 本地明文保存,必须 gitignore,UI 打码显示)。
|
- AI 模型清单(direct 模式 url/模型/密钥/类型/连接超时)→ `config/ai_models.json`(API Key 本地明文保存,必须 gitignore,UI 打码显示)。
|
||||||
|
- cmhub 网关 Key → `config/cmhub.json`,schema `{ "api_key": "..." }`;`config.json` 只保存 Base URL、别名和超时,不保存 Key。
|
||||||
- 业务数据(账号、任务、各阶段结果)→ SQLite `cmshopee.db`。
|
- 业务数据(账号、任务、各阶段结果)→ SQLite `cmshopee.db`。
|
||||||
- 图片(采集的旧封面、AI 生成的新封面)→ 本地图片目录(路径记在 DB)。
|
- 图片(采集的旧封面、AI 生成的新封面)→ 本地图片目录(路径记在 DB)。
|
||||||
- 提示词 → 标题提示词存单文件 `title_prompt.txt`;封面提示词存多模板 `prompts/cover/<名称>.txt`。
|
- 提示词 → 标题提示词存单文件 `title_prompt.txt`;封面提示词存多模板 `prompts/cover/<名称>.txt`。
|
||||||
@@ -103,6 +104,14 @@ imported → collected → generated → applied
|
|||||||
"default_text_model": "GPT-5.5 文本",
|
"default_text_model": "GPT-5.5 文本",
|
||||||
"default_image_model": "Nano Banana 2",
|
"default_image_model": "Nano Banana 2",
|
||||||
"generate_cover": false,
|
"generate_cover": false,
|
||||||
|
"backend": "direct",
|
||||||
|
"cmhub": {
|
||||||
|
"base_url": "",
|
||||||
|
"title_alias": "",
|
||||||
|
"image_alias": "",
|
||||||
|
"connect_timeout": 10,
|
||||||
|
"check_balance_before_batch": false
|
||||||
|
},
|
||||||
"title_concurrency": 4,
|
"title_concurrency": 4,
|
||||||
"image_concurrency": 4,
|
"image_concurrency": 4,
|
||||||
"retry": 2,
|
"retry": 2,
|
||||||
@@ -125,12 +134,14 @@ imported → collected → generated → applied
|
|||||||
|
|
||||||
`ai` 段只放**选择 + 全局生成参数**:
|
`ai` 段只放**选择 + 全局生成参数**:
|
||||||
|
|
||||||
- `default_text_model` / `default_image_model`:引用 `ai_models.json` 里的模型名(标题用文本模型、封面用图像模型)。
|
- `backend`:`direct` / `cmhub`。全新配置和旧配置缺字段时均为 `direct`,避免未配置 cmhub 时破坏既有直连生成;只有用户在⑤显式切换并配置完整后才走 cmhub。
|
||||||
|
- `cmhub`:cmhub 网关配置,`base_url` 为网关根地址,`title_alias` / `image_alias` 为 `GET /api/v1/models` 发现的能力别名,`connect_timeout` 为连接超时;API Key 不在此处保存。
|
||||||
|
- `default_text_model` / `default_image_model`:direct 模式下引用 `ai_models.json` 里的模型名(标题用文本模型、封面用图像模型);cmhub 模式不读取这些模型定义。
|
||||||
- `generate_cover`:②「开始生成」时是否调用图片模型生成新封面;默认 `false`,避免用户无意产生图片生成成本。该字段只控制 AI 生成阶段,不等同于 ③ 的 `allow_cover_update`。
|
- `generate_cover`:②「开始生成」时是否调用图片模型生成新封面;默认 `false`,避免用户无意产生图片生成成本。该字段只控制 AI 生成阶段,不等同于 ③ 的 `allow_cover_update`。
|
||||||
- `resolution`:当前分辨率,下拉 `512 / 1k / 2k / 4k`。
|
- `resolution`:当前分辨率,下拉 `512 / 1k / 2k / 4k`。
|
||||||
- `resolution_timeouts`:分辨率 → **等待大模型返回超时(秒)** 的映射;用户选分辨率即自动套用,不单独填。
|
- `resolution_timeouts`:分辨率 → **等待大模型返回超时(秒)** 的映射;用户选分辨率即自动套用,不单独填。
|
||||||
- 模型本身的定义(url/key/类型/连接超时…)在 `config/ai_models.json`,见 5.1b。
|
- direct 模式模型本身的定义(url/key/类型/连接超时…)在 `config/ai_models.json`,见 5.1b。
|
||||||
- 密钥不在 `config.json`:每个模型的 `api_key` 存于 `config/ai_models.json`,本地明文保存、保存/变更时弹窗提示、UI 打码、gitignore、不入日志/导出。
|
- 密钥不在 `config.json`:direct 模式每个模型的 `api_key` 存于 `config/ai_models.json`;cmhub 模式 API Key 存于 `config/cmhub.json`。两者均本地明文保存、保存/变更时弹窗提示、UI 打码、gitignore、不入日志/导出。
|
||||||
|
|
||||||
`shopee_update` 段放**真实更新前的安全开关**:
|
`shopee_update` 段放**真实更新前的安全开关**:
|
||||||
|
|
||||||
@@ -147,7 +158,7 @@ imported → collected → generated → applied
|
|||||||
|
|
||||||
### 5.1b AI 模型清单 `config/ai_models.json`
|
### 5.1b AI 模型清单 `config/ai_models.json`
|
||||||
|
|
||||||
模型定义清单("有哪些模型"),与 `config.json` 的 `ai` 段("选了哪个 + 全局参数")职责分开。
|
模型定义清单("有哪些模型"),与 `config.json` 的 `ai` 段("选了哪个 + 全局参数")职责分开。该文件只用于 `backend=direct`;`backend=cmhub` 时生文/生图使用 cmhub 别名,不读取此文件。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -177,6 +188,16 @@ imported → collected → generated → applied
|
|||||||
- `name` 唯一;`api_key` 本地明文保存、保存/变更时弹窗提示、打码显示。
|
- `name` 唯一;`api_key` 本地明文保存、保存/变更时弹窗提示、打码显示。
|
||||||
- 日志/状态/导出不得含密码或 API Key;结构化对象统一先过 `appconfig.sanitize_for_log()`,自由文本只允许在掌握明文值时用 `appconfig.redact_secrets()` 替换。
|
- 日志/状态/导出不得含密码或 API Key;结构化对象统一先过 `appconfig.sanitize_for_log()`,自由文本只允许在掌握明文值时用 `appconfig.redact_secrets()` 替换。
|
||||||
|
|
||||||
|
### 5.1c cmhub Key `config/cmhub.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "api_key": "sk_cmhub_xxx" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- 文件必须 gitignore,不提交;UI 展示打码。
|
||||||
|
- `appconfig.load_cmhub_config()` 缺文件时返回空 Key,不自动启用 cmhub。
|
||||||
|
- `backend=cmhub` 但 Base URL、API Key 或别名缺失时,`app/ai.py` 抛 `CMHubError(code="cmhub_not_configured")`,提示去⑤设置配置,不静默回退 direct。
|
||||||
|
|
||||||
### 5.2 SQLite `cmshopee.db`
|
### 5.2 SQLite `cmshopee.db`
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
@@ -374,8 +395,8 @@ images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新封
|
|||||||
|
|
||||||
单个「开始生成」按钮,标题必生成,封面按本轮成本开关可选生成:
|
单个「开始生成」按钮,标题必生成,封面按本轮成本开关可选生成:
|
||||||
|
|
||||||
1. **并发生成标题**:线程池大小 = `title_concurrency`,用 `default_text_model` 调 `gen_title(标题提示词, old_title)` → new_title。
|
1. **并发生成标题**:线程池大小 = `title_concurrency`,调 `gen_title(标题提示词, old_title)` → new_title。`backend=direct` 时使用 `default_text_model`;`backend=cmhub` 时调用 `POST /api/v1/generate/title` 并使用 `title_alias`。
|
||||||
2. **若②勾选「生成封面图片(成本较高)」**:接着并发生成图片,线程池大小 = `image_concurrency`,用 `default_image_model` 调 `gen_cover(封面提示词, old_cover_path, resolution, jpg_quality)` → 新图存 `images/<batch_id>/<slug>/<task_id>_<item_id>_new.jpg`。
|
2. **若②勾选「生成封面图片(成本较高)」**:接着并发生成图片,线程池大小 = `image_concurrency`,调 `gen_cover(封面提示词, old_cover_path, resolution, jpg_quality)` → 新图存 `images/<batch_id>/<slug>/<task_id>_<item_id>_new.jpg`。`backend=direct` 时使用 `default_image_model`;`backend=cmhub` 时调用 `POST /api/v1/generate/image`,拿 `image_url` 后立即安全下载并转本地 JPEG。
|
||||||
- 连接超时取该模型 `connect_timeout_seconds`;**返回超时取 `resolution_timeouts[resolution]`**(512→180/1k→240/2k→360/4k→600)。
|
- 连接超时取该模型 `connect_timeout_seconds`;**返回超时取 `resolution_timeouts[resolution]`**(512→180/1k→240/2k→360/4k→600)。
|
||||||
3. **若未勾选生成封面**:标题成功后立即写 `new_title`,`new_cover_path=NULL`,不渲染封面提示词、不调用 `gen_cover()`、不创建本地新封面文件。
|
3. **若未勾选生成封面**:标题成功后立即写 `new_title`,`new_cover_path=NULL`,不渲染封面提示词、不调用 `gen_cover()`、不创建本地新封面文件。
|
||||||
|
|
||||||
@@ -462,7 +483,8 @@ cmshopee/
|
|||||||
├── main.py # GUI 启动入口:from app.gui import main
|
├── main.py # GUI 启动入口:from app.gui import main
|
||||||
├── shopee待处理任务模板.xlsx # 标准空 Excel 模板,可提交;业务填写后的副本不提交
|
├── shopee待处理任务模板.xlsx # 标准空 Excel 模板,可提交;业务填写后的副本不提交
|
||||||
├── config.json # 应用配置(模型选择/生成参数/路径,gitignore)
|
├── config.json # 应用配置(模型选择/生成参数/路径,gitignore)
|
||||||
├── config/ai_models.json # AI 模型清单(含密钥,必须 gitignore)
|
├── config/ai_models.json # direct AI 模型清单(含密钥,必须 gitignore)
|
||||||
|
├── config/cmhub.json # cmhub API Key(含密钥,必须 gitignore)
|
||||||
├── cmshopee.db # SQLite(账号/任务/结果,gitignore)
|
├── cmshopee.db # SQLite(账号/任务/结果,gitignore)
|
||||||
├── chrome_user_data_dir/ # 各账号 Chrome 配置(含登录态,gitignore)
|
├── chrome_user_data_dir/ # 各账号 Chrome 配置(含登录态,gitignore)
|
||||||
├── images/ # 旧封面/新封面本地图片(gitignore)
|
├── images/ # 旧封面/新封面本地图片(gitignore)
|
||||||
@@ -472,7 +494,7 @@ cmshopee/
|
|||||||
# 逻辑待并入 app/editor.py 后清理;见 prototypes/README.md
|
# 逻辑待并入 app/editor.py 后清理;见 prototypes/README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
> `config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 含密钥/凭证/业务数据,必须 gitignore。`shopee待处理任务模板.xlsx` 是标准空模板,可以提交;运营填写后的 Excel 副本属于业务数据,不提交。
|
> `config.json`、`config/ai_models.json`、`config/cmhub.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 含配置/密钥/凭证/业务数据,必须 gitignore。`shopee待处理任务模板.xlsx` 是标准空模板,可以提交;运营填写后的 Excel 副本属于业务数据,不提交。
|
||||||
|
|
||||||
## 十、架构纪律
|
## 十、架构纪律
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -124,7 +124,7 @@
|
|||||||
|
|
||||||
| ID | 任务 | 依赖 | 验收要点 | 状态 |
|
| ID | 任务 | 依赖 | 验收要点 | 状态 |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| T-526 | `app/ai.py` + `appconfig` 接入 cmhub backend | T-301, T-303, T-520 | 依据 `docs/cmhub-integration-design.md` v3.2。`config.json` 的 `ai` 段加 `backend`(`cmhub`/`direct`)+ `cmhub` 子段(`base_url`/`title_alias`/`image_alias`/`connect_timeout` 等);为保护既有用户,全新配置默认 `backend=direct`、加载既有配置缺 `backend` 时也补 `direct`,`cmhub` 一律由用户在⑤显式 opt-in;`backend=cmhub` 但 `base_url`/Key 缺失时须抛清晰「请去⑤配置 cmhub」错误(`CMHubError`/`AIError`),不崩溃、不静默直连。cmhub API Key 固定存 `config/cmhub.json`(schema `{ "api_key": "..." }`),新增读写/打码 helper 并把该文件加入 `.gitignore`;日志脱敏。`gen_title`/`gen_cover` **返回值不变**,内层按 backend 分流并保留 `direct`;计费元数据不塞进返回值,允许给二者**新增一个可选事件回调参数**(如 `on_meta`/`on_event`)承载,属向后兼容加参,`generate_batch` 显式传回调不受影响。cmhub 分支:生文 `POST /api/v1/generate/title` 体 `{prompt,model:别名,resolution?}`、取 `titles[0]`、空则 `AIError`;生图 `POST /api/v1/generate/image` 体 `{prompt,model:别名,image_base64:<旧封面>,resolution,aspect_ratio:"1:1"}`、拿 `image_url` 后**立即下载**再走 `_save_jpeg`;`resolution` 归一大写 `512/1K/2K/4K`。新增 `CMHubError(AIError)`,带 `code/status/retryable/retry_after`,错误按 `code` 优先分支(`insufficient_points`/`unauthorized`/`account_disabled`/`bad_request`/`model_not_allowed`/`no_pricing_rule`/`content_blocked`/`upstream_error`/`rate_limited`,未知 code 当不可重试);cmhub HTTP helper 需区分 connect/read timeout(优先用 `requests timeout=(connect, read)`),只对 502/429/连接超时重试,生图读超时绝不自动重发,读超时按分辨率封顶 600s。`points_cost`/`points_balance`/`call_id` 不改返回值,通过 `on_step`/事件回调上报;T-526 只保证 metadata 事件完整传出,T-528 再由 GUI worker 脱敏写 run_logs 和余额展示。`image_url` 下载必须限制 http/https、拒绝内网/回环地址、校验域名解析后的 IP 仍不是内网/回环/本机地址,并设置超时和大小上限。新增 `fetch_cmhub_models(base_url, api_key)` helper 调 `GET /api/v1/models` 返回别名清单(`alias/operation_type/requires_image/pricing_status/prices`)供 T-527 渲染下拉,错误脱敏。不碰 editor/cdp/chrome/accounts/excel/db,也不改 ①③④流程。`tests/test_ai.py` 加 cmhub mock(titles 列表、image_url 下载、安全下载、错误码与重试、读超时不重发、metadata 事件)、`tests/test_appconfig.py` 加 schema 和 key 文件 helper,direct 用例保持绿 | TODO |
|
| T-526 | `app/ai.py` + `appconfig` 接入 cmhub backend | T-301, T-303, T-520 | 依据 `docs/cmhub-integration-design.md` v3.2。`config.json` 的 `ai` 段加 `backend`(`cmhub`/`direct`)+ `cmhub` 子段(`base_url`/`title_alias`/`image_alias`/`connect_timeout` 等);为保护既有用户,全新配置默认 `backend=direct`、加载既有配置缺 `backend` 时也补 `direct`,`cmhub` 一律由用户在⑤显式 opt-in;`backend=cmhub` 但 `base_url`/Key 缺失时须抛清晰「请去⑤配置 cmhub」错误(`CMHubError`/`AIError`),不崩溃、不静默直连。cmhub API Key 固定存 `config/cmhub.json`(schema `{ "api_key": "..." }`),新增读写/打码 helper 并把该文件加入 `.gitignore`;日志脱敏。`gen_title`/`gen_cover` **返回值不变**,内层按 backend 分流并保留 `direct`;计费元数据不塞进返回值,允许给二者**新增一个可选事件回调参数**(如 `on_meta`/`on_event`)承载,属向后兼容加参,`generate_batch` 显式传回调不受影响。cmhub 分支:生文 `POST /api/v1/generate/title` 体 `{prompt,model:别名,resolution?}`、取 `titles[0]`、空则 `AIError`;生图 `POST /api/v1/generate/image` 体 `{prompt,model:别名,image_base64:<旧封面>,resolution,aspect_ratio:"1:1"}`、拿 `image_url` 后**立即下载**再走 `_save_jpeg`;`resolution` 归一大写 `512/1K/2K/4K`。新增 `CMHubError(AIError)`,带 `code/status/retryable/retry_after`,错误按 `code` 优先分支(`insufficient_points`/`unauthorized`/`account_disabled`/`bad_request`/`model_not_allowed`/`no_pricing_rule`/`content_blocked`/`upstream_error`/`rate_limited`,未知 code 当不可重试);cmhub HTTP helper 需区分 connect/read timeout(优先用 `requests timeout=(connect, read)`),只对 502/429/连接超时重试,生图读超时绝不自动重发,读超时按分辨率封顶 600s。`points_cost`/`points_balance`/`call_id` 不改返回值,通过 `on_step`/事件回调上报;T-526 只保证 metadata 事件完整传出,T-528 再由 GUI worker 脱敏写 run_logs 和余额展示。`image_url` 下载必须限制 http/https、拒绝内网/回环地址、校验域名解析后的 IP 仍不是内网/回环/本机地址,并设置超时和大小上限。新增 `fetch_cmhub_models(base_url, api_key)` helper 调 `GET /api/v1/models` 返回别名清单(`alias/operation_type/requires_image/pricing_status/prices`)供 T-527 渲染下拉,错误脱敏。不碰 editor/cdp/chrome/accounts/excel/db,也不改 ①③④流程。`tests/test_ai.py` 加 cmhub mock(titles 列表、image_url 下载、安全下载、错误码与重试、读超时不重发、metadata 事件)、`tests/test_appconfig.py` 加 schema 和 key 文件 helper,direct 用例保持绿 | DONE |
|
||||||
| T-527 | ⑤设置 cmhub 网关面板 | T-526, T-517 | 依据 `docs/cmhub-integration-design.md` v3.2。⑤ AI 设置按 `backend` 切换:cmhub 模式显示「网关 Base URL + API Key(打码,提示从网页端复制、仅显示一次)+ 生文别名 + 生图别名 + 测试连接/查余额」;**别名从 `GET /api/v1/models`(T-526 的 `fetch_cmhub_models`)动态拉取渲染下拉**,按 `operation_type` 分生文/生图,过滤 `pricing_status="unpriced"` 的别名,可展示单价与 `requires_image` 提示,选中值持久化到 `ai.cmhub.title_alias/image_alias`(网关临时不可达时回退已存值);不写死别名。direct 模式保留现有 AI 模型 master-detail。保存写 `config.json` 的 `ai` 段与 `config/cmhub.json`;切换 backend 时不删除 legacy `config/ai_models.json`。测试连接/查余额经后台 worker 调 cmhub(复用 `AIModelTestWorker` 思路或新增 worker),错误必须脱敏并给用户可读提示。同步 GUI 设置测试;不改 Shopee/CDP 流程 | TODO |
|
| T-527 | ⑤设置 cmhub 网关面板 | T-526, T-517 | 依据 `docs/cmhub-integration-design.md` v3.2。⑤ AI 设置按 `backend` 切换:cmhub 模式显示「网关 Base URL + API Key(打码,提示从网页端复制、仅显示一次)+ 生文别名 + 生图别名 + 测试连接/查余额」;**别名从 `GET /api/v1/models`(T-526 的 `fetch_cmhub_models`)动态拉取渲染下拉**,按 `operation_type` 分生文/生图,过滤 `pricing_status="unpriced"` 的别名,可展示单价与 `requires_image` 提示,选中值持久化到 `ai.cmhub.title_alias/image_alias`(网关临时不可达时回退已存值);不写死别名。direct 模式保留现有 AI 模型 master-detail。保存写 `config.json` 的 `ai` 段与 `config/cmhub.json`;切换 backend 时不删除 legacy `config/ai_models.json`。测试连接/查余额经后台 worker 调 cmhub(复用 `AIModelTestWorker` 思路或新增 worker),错误必须脱敏并给用户可读提示。同步 GUI 设置测试;不改 Shopee/CDP 流程 | TODO |
|
||||||
| T-528 | ② 计费错误提示 + 余额展示 | T-526, T-527, T-303 | 依据 `docs/cmhub-integration-design.md` v3.2。② AI生成页把 cmhub 计费失败态显式化:通过 `CMHubError.code` 识别 `insufficient_points`,弹明确提示「点数不足,请先充值」并引导去网页端充值,本轮未开始任务可提前中止,不靠中文字符串匹配、不淹没在失败计数里;用 T-526 成功响应事件里的 `points_balance` 刷新②页剩余点数显示,`/balance` 仅作手动刷新/可选批量前预检;`points_cost`/`call_id` 记入脱敏 run_logs。只改② UI、`GenerateWorker` 事件处理/文案及 GUI 单测;不改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程 | TODO |
|
| T-528 | ② 计费错误提示 + 余额展示 | T-526, T-527, T-303 | 依据 `docs/cmhub-integration-design.md` v3.2。② AI生成页把 cmhub 计费失败态显式化:通过 `CMHubError.code` 识别 `insufficient_points`,弹明确提示「点数不足,请先充值」并引导去网页端充值,本轮未开始任务可提前中止,不靠中文字符串匹配、不淹没在失败计数里;用 T-526 成功响应事件里的 `points_balance` 刷新②页剩余点数显示,`/balance` 仅作手动刷新/可选批量前预检;`points_cost`/`call_id` 记入脱敏 run_logs。只改② UI、`GenerateWorker` 事件处理/文案及 GUI 单测;不改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程 | TODO |
|
||||||
|
|
||||||
|
|||||||
+26
-9
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
- 形态:本地函数 + 子进程(Chrome)+ CDP(`127.0.0.1:<port>`)+ SQLite + openpyxl + AI 服务调用。
|
- 形态:本地函数 + 子进程(Chrome)+ CDP(`127.0.0.1:<port>`)+ SQLite + openpyxl + AI 服务调用。
|
||||||
- 编码:UTF-8;传 Chrome / `setFileInputFiles` 的路径为 **Windows 绝对路径**。
|
- 编码:UTF-8;传 Chrome / `setFileInputFiles` 的路径为 **Windows 绝对路径**。
|
||||||
- 凭证:登录态在 user-data-dir;密码、AI Key 本地明文存于 config/DB;`config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 必须 gitignore;UI 打码显示,不出现在日志/导出。
|
- 凭证:登录态在 user-data-dir;密码、AI Key 本地明文存于 config/DB;`config.json`、`config/ai_models.json`、`config/cmhub.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 必须 gitignore;UI 打码显示,不出现在日志/导出。
|
||||||
- 失败处理:抛带中文说明的异常或返回状态字段;GUI 负责提示,不静默吞错。
|
- 失败处理:抛带中文说明的异常或返回状态字段;GUI 负责提示,不静默吞错。
|
||||||
|
|
||||||
## appconfig 模块(`app/appconfig.py`,已建)
|
## appconfig 模块(`app/appconfig.py`,已建)
|
||||||
@@ -29,10 +29,13 @@ cdp_ready_timeout(config=None) -> int
|
|||||||
ai_config(config=None) -> dict # default_text_model/default_image_model/
|
ai_config(config=None) -> dict # default_text_model/default_image_model/
|
||||||
# title_concurrency/image_concurrency/retry/jpg_quality/
|
# title_concurrency/image_concurrency/retry/jpg_quality/
|
||||||
# resolution/resolution_timeouts
|
# resolution/resolution_timeouts
|
||||||
|
ai_backend(config=None) -> str # direct / cmhub
|
||||||
|
cmhub_config(config=None) -> dict # base_url/title_alias/image_alias/connect_timeout
|
||||||
|
cmhub_request_url(base_url, endpoint) -> str
|
||||||
response_timeout(config=None) -> int # = resolution_timeouts[resolution](返回超时,随分辨率)
|
response_timeout(config=None) -> int # = resolution_timeouts[resolution](返回超时,随分辨率)
|
||||||
```
|
```
|
||||||
|
|
||||||
`default_config()` / `load_config()` 包含 `shopee_update` 安全配置段:历史/调试兼容测试商品 ID、是否允许真实提交、是否允许更新封面、每批最大更新条数、成功后是否关闭本轮新开编辑页、内部兼容 `dry_run`、多账号并行、最大并行账号数。普通正式更新不再用测试商品 ID 阻断当前筛选结果。`config.json` 不保存 AI Key;写入 `api_key` / `*_key` / `token` / `*_token` / `password` / `*_password` 等敏感字段时抛 `ConfigError`。AI Key 留给 `config/ai_models.json`。
|
`default_config()` / `load_config()` 包含 `shopee_update` 安全配置段:历史/调试兼容测试商品 ID、是否允许真实提交、是否允许更新封面、每批最大更新条数、成功后是否关闭本轮新开编辑页、内部兼容 `dry_run`、多账号并行、最大并行账号数。普通正式更新不再用测试商品 ID 阻断当前筛选结果。`config.json` 不保存 AI Key;写入 `api_key` / `*_key` / `token` / `*_token` / `password` / `*_password` 等敏感字段时抛 `ConfigError`。AI Key 留给 `config/ai_models.json`(direct)或 `config/cmhub.json`(cmhub)。
|
||||||
|
|
||||||
敏感信息展示/日志辅助:
|
敏感信息展示/日志辅助:
|
||||||
|
|
||||||
@@ -42,6 +45,17 @@ sanitize_for_log(value) -> object # 递归打码 api_key/password/tok
|
|||||||
redact_secrets(text, secret_values=None) -> str # 用已知明文值替换自由文本中的秘密
|
redact_secrets(text, secret_values=None) -> str # 用已知明文值替换自由文本中的秘密
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
cmhub Key 文件(`config/cmhub.json`,含本地明文密钥,T-526 已建;UI 由 T-527 接入):
|
||||||
|
|
||||||
|
```python
|
||||||
|
default_cmhub_config() -> dict
|
||||||
|
load_cmhub_config(path="config/cmhub.json") -> dict # 缺文件返回空 key,不自动启用 cmhub
|
||||||
|
save_cmhub_config(config, path="config/cmhub.json") -> dict
|
||||||
|
get_cmhub_api_key(path="config/cmhub.json", masked=False) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
`config.json` 只保存 `ai.backend`、`ai.cmhub.base_url/title_alias/image_alias/connect_timeout` 等非密钥配置;`config/cmhub.json` 必须 gitignore,展示时打码,不写日志/导出。
|
||||||
AI 模型清单(`config/ai_models.json`,含本地明文密钥,已建;UI 由 ⑤ 设置复用):
|
AI 模型清单(`config/ai_models.json`,含本地明文密钥,已建;UI 由 ⑤ 设置复用):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -271,14 +285,15 @@ apply_task(account, task, close_success_tab=False) -> dict
|
|||||||
```python
|
```python
|
||||||
class AIError(RuntimeError): ...
|
class AIError(RuntimeError): ...
|
||||||
|
|
||||||
gen_title(title_prompt, old_title, retry=None, config=None, models_path="config/ai_models.json", on_step=None) -> str
|
gen_title(title_prompt, old_title, retry=None, config=None, models_path="config/ai_models.json", on_step=None, on_event=None, cmhub_config_path="config/cmhub.json") -> str
|
||||||
# 文本生成:读取 default_text_model,chat JSON 请求;提示词 + 旧标题 → 新标题
|
# 文本生成:按 backend 分流;direct 走 chat JSON,cmhub 走 /generate/title;提示词 + 旧标题 → 新标题
|
||||||
|
|
||||||
gen_cover(cover_prompt, old_cover_path, out_path, resolution=None, jpg_quality=None, retry=None, config=None, models_path="config/ai_models.json", on_step=None) -> str
|
gen_cover(cover_prompt, old_cover_path, out_path, resolution=None, jpg_quality=None, retry=None, config=None, models_path="config/ai_models.json", on_step=None, on_event=None, cmhub_config_path="config/cmhub.json") -> str
|
||||||
# 图像生成(image-to-image):读取 default_image_model;chat 多模态 JSON 或 images_edits multipart;
|
# 图像生成(image-to-image):按 backend 分流;direct 走 chat/images_edits,cmhub 走 /generate/image;
|
||||||
# 支持返回 url / data URL / b64_json,按 resolution resize 并以 jpg_quality 保存 JPEG,返回路径;新生成默认写入 `image_dir/<batch_id>/<slug>/<task_id>_<item_id>_new.jpg`,历史 DB 已存路径继续按原路径读取
|
# 支持返回 url / data URL / b64_json,按 resolution resize 并以 jpg_quality 保存 JPEG,返回路径;新生成默认写入 `image_dir/<batch_id>/<slug>/<task_id>_<item_id>_new.jpg`,历史 DB 已存路径继续按原路径读取
|
||||||
|
|
||||||
generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None) -> dict
|
generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None) -> dict
|
||||||
|
fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30) -> list[dict]
|
||||||
# 编排:先以 title_concurrency 线程池并发跑 gen_title;ai.generate_cover 为 true 时再以 image_concurrency 并发跑 gen_cover
|
# 编排:先以 title_concurrency 线程池并发跑 gen_title;ai.generate_cover 为 true 时再以 image_concurrency 并发跑 gen_cover
|
||||||
# 标题-only 模式标题成功即 db.set_generated(task_id, new_title, None);标题+封面模式图片成功后写 new_cover_path;should_stop() 为真则取消未开始项
|
# 标题-only 模式标题成功即 db.set_generated(task_id, new_title, None);标题+封面模式图片成功后写 new_cover_path;should_stop() 为真则取消未开始项
|
||||||
# on_progress({"total","title_done","cover_done","failed","cancelled","ok"}) 回调刷新进度
|
# on_progress({"total","title_done","cover_done","failed","cancelled","ok"}) 回调刷新进度
|
||||||
@@ -288,10 +303,12 @@ generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None)
|
|||||||
|
|
||||||
要点:
|
要点:
|
||||||
|
|
||||||
- 标题用 `default_text_model`、封面用 `default_image_model`(`appconfig.get_model` 取定义,含 url/key/api_type)。
|
- `backend=direct`:标题用 `default_text_model`、封面用 `default_image_model`(`appconfig.get_model` 取定义,含 url/key/api_type)。
|
||||||
|
- `backend=cmhub`:标题调用 `POST /api/v1/generate/title`,封面调用 `POST /api/v1/generate/image`,模型字段使用 `ai.cmhub.title_alias/image_alias`,Key 来自 `config/cmhub.json`。
|
||||||
|
- `fetch_cmhub_models()` 调 `GET /api/v1/models` 返回别名清单,供 T-527 设置页动态下拉使用。
|
||||||
- `api_type=chat/auto` 走 OpenAI-compatible chat JSON;`api_type=images_edits` 走 multipart form。
|
- `api_type=chat/auto` 走 OpenAI-compatible chat JSON;`api_type=images_edits` 走 multipart form。
|
||||||
- 连接超时参考模型 `connect_timeout_seconds`;**返回超时 = 模型 `timeout_seconds` 或 `appconfig.response_timeout()`(随分辨率:512/1k/2k/4k → 180/240/360/600)**。
|
- direct 连接超时参考模型 `connect_timeout_seconds`;**返回超时 = 模型 `timeout_seconds` 或 `appconfig.response_timeout()`(随分辨率:512/1k/2k/4k → 180/240/360/600)**。cmhub 使用 `requests timeout=(connect, read)`,connect 来自 `ai.cmhub.connect_timeout`,read 随分辨率且封顶 600s。
|
||||||
- 并发数/重试/分辨率/jpg 质量来自 `appconfig.ai_config()`;Key 本地明文存储,但不入日志、不导出。
|
- 并发数/重试/分辨率/jpg 质量来自 `appconfig.ai_config()`;Key 本地明文存储,但不入日志、不导出。cmhub 响应的 `points_cost/points_balance/call_id` 不改变返回值,通过 `on_event` metadata 事件上报,GUI 余额/计费展示留给 T-528。
|
||||||
- 标题快、图片慢:分两段、各用各自并发数;失败按 `retry` 重试,仍失败记 error 不阻塞其余。
|
- 标题快、图片慢:分两段、各用各自并发数;失败按 `retry` 重试,仍失败记 error 不阻塞其余。
|
||||||
- 调用有成本与失败可能:超时、限流、内容安全拒绝都要返回明确错误。
|
- 调用有成本与失败可能:超时、限流、内容安全拒绝都要返回明确错误。
|
||||||
- 生成结果**直接进入 ③ 更新候选**;③ 点击「开始更新」后弹窗批量确认,确认后提交线上。本地留档 + 回写 Excel 供追溯。
|
- 生成结果**直接进入 ③ 更新候选**;③ 点击「开始更新」后弹窗批量确认,确认后提交线上。本地留档 + 回写 Excel 供追溯。
|
||||||
|
|||||||
+15
-15
File diff suppressed because one or more lines are too long
+19
@@ -1095,3 +1095,22 @@
|
|||||||
- 文档:新增 `docs/packaging.md`,同步 `docs/README.md`、`docs/03-tech-stack.md`、`docs/06-tasks.md`、`docs/current-state.md`;T-524 标为 DONE,下一个可领取任务更新为 T-525。
|
- 文档:新增 `docs/packaging.md`,同步 `docs/README.md`、`docs/03-tech-stack.md`、`docs/06-tasks.md`、`docs/current-state.md`;T-524 标为 DONE,下一个可领取任务更新为 T-525。
|
||||||
- 测试:新增 `tests/test_packaging.py` 覆盖 frozen 入口路径、spec 不打包本地数据、构建脚本排除列表。
|
- 测试:新增 `tests/test_packaging.py` 覆盖 frozen 入口路径、spec 不打包本地数据、构建脚本排除列表。
|
||||||
- 验证:`python -m compileall app main.py` 通过;`python -m unittest discover -s tests -p "test_packaging.py"` 通过(3 tests);`python -m unittest discover -s tests` 通过(170 tests);`git diff --check` 无空白错误(仅 LF/CRLF 提示);`powershell -ExecutionPolicy Bypass -File scripts\build_exe.ps1` 成功产出 `dist\cmshopee\cmshopee.exe`,脚本确认未混入本地数据。本机当前 `python` 为 3.7.9,构建脚本已提示低于项目目标;正式发布建议切到 Python 3.10+ 环境后重新打包。
|
- 验证:`python -m compileall app main.py` 通过;`python -m unittest discover -s tests -p "test_packaging.py"` 通过(3 tests);`python -m unittest discover -s tests` 通过(170 tests);`git diff --check` 无空白错误(仅 LF/CRLF 提示);`powershell -ExecutionPolicy Bypass -File scripts\build_exe.ps1` 成功产出 `dist\cmshopee\cmshopee.exe`,脚本确认未混入本地数据。本机当前 `python` 为 3.7.9,构建脚本已提示低于项目目标;正式发布建议切到 Python 3.10+ 环境后重新打包。
|
||||||
|
## 【2026-07-04】文档修正 · cmhub 网关对接任务定义
|
||||||
|
|
||||||
|
- 背景:Claude Code 已新增 `docs/cmhub-integration-design.md` 与 T-526~T-528;本轮从全栈工程角度评审后修正任务边界,避免未配置 cmhub 时破坏现有 direct 生文/生图流程。
|
||||||
|
- 修正:`docs/cmhub-integration-design.md` 增补 v3 评审结论,明确旧配置缺 `backend` 时按 `direct` 迁移、cmhub Key 固定存 `config/cmhub.json`、新增 `CMHubError` 结构化错误、点数/余额通过事件回调传播、cmhub HTTP 调用需区分 connect/read timeout、`image_url` 下载需安全校验。
|
||||||
|
- 任务:`docs/06-tasks.md` 将业务优先级调整为 Phase 7 cmhub,对 T-526/T-527/T-528 验收重写;T-525 ruff 顺延到 Phase 8。`docs/current-state.md` 下一个可领取任务改为 T-526。
|
||||||
|
- 验证:文档-only 更新,未改代码,未运行单元测试。
|
||||||
|
## 【2026-07-04】文档修正 · cmhub v3.2 六点一致性收口
|
||||||
|
|
||||||
|
- 修正:`docs/cmhub-integration-design.md` 将目标接口改为 title/image/balance/models 四类接口;统一 `gen_title`/`gen_cover` 为“返回值与现有调用兼容,可新增可选事件回调参数”;把不存在的 `default_ai_config()` 改为现有 `DEFAULT_CONFIG` / `default_config()` 表述;明确 T-526 只发出计费 metadata 事件,T-528 再负责 GUI run_logs 与余额展示;补充 `image_url` 域名解析后 IP 也要拒绝内网/回环/本机地址。
|
||||||
|
- 任务:`docs/06-tasks.md` 将 T-526/T-527/T-528 引用统一为 `docs/cmhub-integration-design.md` v3.2,并同步 T-526 的 run_logs 边界与安全下载验收。
|
||||||
|
- 验证:文档-only 更新,未改代码,未运行单元测试。
|
||||||
|
## 【2026-07-04】T-526 完成 · `app/ai.py` + `appconfig` 接入 cmhub backend
|
||||||
|
|
||||||
|
- 状态:DONE
|
||||||
|
- 变更:`app/appconfig.py` 在 `config.json` 的 `ai` 段新增 `backend=direct/cmhub` 和 `ai.cmhub` 默认配置,新增 `config/cmhub.json` 的读写/打码 helper;`.gitignore` 增加 `config/cmhub.json`。`app/ai.py` 保留 direct 直连模型清单路径,新增 cmhub 生文、生图和 `/models` 别名发现 helper;`gen_title()` / `gen_cover()` 返回值不变,cmhub 计费 metadata 通过事件回调传出;新增 `CMHubError` 结构化错误、按 code 的重试策略、tuple timeout、生图读超时不自动重发、`image_url` http/https + DNS/IP 内网拦截 + 大小上限下载。
|
||||||
|
- 文档:同步 `docs/03-tech-stack.md`、`docs/04-architecture.md`、`docs/api.md`、`docs/06-tasks.md`、`docs/current-state.md`;T-526 标记 DONE,下一步为 T-527。
|
||||||
|
- 测试:`python -m py_compile app\appconfig.py app\ai.py tests\test_appconfig.py tests\test_ai.py` 通过;`python -m unittest discover -s tests -p "test_appconfig.py"` 通过(8 tests);`python -m unittest discover -s tests -p "test_ai.py"` 通过(16 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(179 tests)。全量测试仍有本机 PySide6 字体目录提示,不影响结果。
|
||||||
|
- 决策:全新配置和旧配置缺 `ai.backend` 时都保持 `direct`,避免升级后未配置 cmhub 就破坏现有生成;cmhub Key 固定只进 `config/cmhub.json`,不进 `config.json`;T-526 只传出 metadata,GUI 余额/计费提示留给 T-528。
|
||||||
|
- 下一步:T-527 ⑤设置 cmhub 网关面板。
|
||||||
@@ -2,6 +2,7 @@ import base64
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import socket
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -28,6 +29,22 @@ class _Response:
|
|||||||
return json.dumps(self.payload).encode("utf-8")
|
return json.dumps(self.payload).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class _RequestsResponse:
|
||||||
|
def __init__(self, payload=None, status_code=200, content=b"", headers=None):
|
||||||
|
self.payload = payload if payload is not None else {}
|
||||||
|
self.status_code = status_code
|
||||||
|
self.content = content
|
||||||
|
self.headers = headers or {}
|
||||||
|
self.text = json.dumps(self.payload, ensure_ascii=False)
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self.payload
|
||||||
|
|
||||||
|
def iter_content(self, chunk_size=65536):
|
||||||
|
if self.content:
|
||||||
|
yield self.content
|
||||||
|
|
||||||
class AITests(TempDirMixin, unittest.TestCase):
|
class AITests(TempDirMixin, unittest.TestCase):
|
||||||
def _write_models(self, path, text=None, image=None):
|
def _write_models(self, path, text=None, image=None):
|
||||||
text = text or {
|
text = text or {
|
||||||
@@ -65,6 +82,21 @@ class AITests(TempDirMixin, unittest.TestCase):
|
|||||||
cfg["ai"]["jpg_quality"] = 80
|
cfg["ai"]["jpg_quality"] = 80
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
def _cmhub_config(self, temp_dir):
|
||||||
|
cfg = self._config()
|
||||||
|
cfg["ai"]["backend"] = "cmhub"
|
||||||
|
cfg["ai"]["resolution"] = "512"
|
||||||
|
cfg["ai"]["cmhub"] = {
|
||||||
|
"base_url": "https://cmhub.example.com",
|
||||||
|
"title_alias": "title-standard",
|
||||||
|
"image_alias": "image-hd",
|
||||||
|
"connect_timeout": 3,
|
||||||
|
"check_balance_before_batch": False,
|
||||||
|
}
|
||||||
|
key_path = os.path.join(temp_dir, "cmhub.json")
|
||||||
|
appconfig.save_cmhub_config({"api_key": "sk-cmhub-secret"}, path=key_path)
|
||||||
|
return cfg, key_path
|
||||||
|
|
||||||
def _collected_tasks(self, temp_dir, cfg, titles=None):
|
def _collected_tasks(self, temp_dir, cfg, titles=None):
|
||||||
titles = titles or ["旧标题A", "旧标题B"]
|
titles = titles or ["旧标题A", "旧标题B"]
|
||||||
db.init_db(cfg["db_path"])
|
db.init_db(cfg["db_path"])
|
||||||
@@ -240,6 +272,293 @@ class AITests(TempDirMixin, unittest.TestCase):
|
|||||||
|
|
||||||
self.assert_removed(temp_dir)
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_cmhub_gen_title_uses_alias_and_emits_metadata(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
cfg, key_path = self._cmhub_config(temp_dir)
|
||||||
|
calls = []
|
||||||
|
events = []
|
||||||
|
|
||||||
|
def fake_request(method, url, **kwargs):
|
||||||
|
calls.append((method, url, kwargs))
|
||||||
|
return _RequestsResponse(
|
||||||
|
{
|
||||||
|
"titles": [" 新标题 "],
|
||||||
|
"alias": "title-standard",
|
||||||
|
"model_used": "provider-title-model",
|
||||||
|
"points_cost": 1,
|
||||||
|
"points_balance": 99,
|
||||||
|
"call_id": "call-title-1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch("app.ai.requests.request", side_effect=fake_request):
|
||||||
|
title = ai.gen_title(
|
||||||
|
"优化标题",
|
||||||
|
"旧标题",
|
||||||
|
config=cfg,
|
||||||
|
cmhub_config_path=key_path,
|
||||||
|
on_event=events.append,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("新标题", title)
|
||||||
|
self.assertEqual(1, len(calls))
|
||||||
|
method, url, kwargs = calls[0]
|
||||||
|
self.assertEqual("POST", method)
|
||||||
|
self.assertEqual("https://cmhub.example.com/api/v1/generate/title", url)
|
||||||
|
self.assertEqual((3, 180), kwargs["timeout"])
|
||||||
|
self.assertEqual("Bearer sk-cmhub-secret", kwargs["headers"]["Authorization"])
|
||||||
|
payload = kwargs["json"]
|
||||||
|
self.assertEqual("title-standard", payload["model"])
|
||||||
|
self.assertEqual("512", payload["resolution"])
|
||||||
|
self.assertIn("优化标题", payload["prompt"])
|
||||||
|
self.assertIn("旧标题", payload["prompt"])
|
||||||
|
self.assertTrue(events)
|
||||||
|
self.assertEqual("meta", events[0]["result"])
|
||||||
|
self.assertEqual(99, events[0]["metadata"]["points_balance"])
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_cmhub_missing_config_raises_clear_error(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
cfg = self._config()
|
||||||
|
cfg["ai"]["backend"] = "cmhub"
|
||||||
|
cfg["ai"]["cmhub"] = {
|
||||||
|
"base_url": "",
|
||||||
|
"title_alias": "",
|
||||||
|
"image_alias": "",
|
||||||
|
"connect_timeout": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
with self.assertRaises(ai.CMHubError) as raised:
|
||||||
|
ai.gen_title(
|
||||||
|
"prompt",
|
||||||
|
"old",
|
||||||
|
config=cfg,
|
||||||
|
cmhub_config_path=os.path.join(temp_dir, "missing.json"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("cmhub_not_configured", raised.exception.code)
|
||||||
|
self.assertIn("请去⑤设置配置 cmhub", str(raised.exception))
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_cmhub_gen_cover_downloads_image_url_safely(self):
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
except ImportError:
|
||||||
|
self.skipTest("Pillow not installed")
|
||||||
|
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
cfg, key_path = self._cmhub_config(temp_dir)
|
||||||
|
cfg["ai"]["resolution"] = "1k"
|
||||||
|
old_cover = os.path.join(temp_dir, "old.jpg")
|
||||||
|
output = os.path.join(temp_dir, "new.jpg")
|
||||||
|
Image.new("RGB", (16, 16), (20, 30, 40)).save(old_cover, "JPEG")
|
||||||
|
generated = io.BytesIO()
|
||||||
|
Image.new("RGB", (8, 8), (200, 120, 80)).save(generated, "PNG")
|
||||||
|
calls = []
|
||||||
|
downloads = []
|
||||||
|
events = []
|
||||||
|
|
||||||
|
def fake_request(method, url, **kwargs):
|
||||||
|
calls.append((method, url, kwargs))
|
||||||
|
return _RequestsResponse(
|
||||||
|
{
|
||||||
|
"image_url": "https://cdn.example.com/generated.png",
|
||||||
|
"alias": "image-hd",
|
||||||
|
"model_used": "provider-image-model",
|
||||||
|
"points_cost": 8,
|
||||||
|
"points_balance": 91,
|
||||||
|
"call_id": "call-image-1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_get(url, **kwargs):
|
||||||
|
downloads.append((url, kwargs))
|
||||||
|
return _RequestsResponse(content=generated.getvalue())
|
||||||
|
|
||||||
|
with mock.patch("app.ai.requests.request", side_effect=fake_request), \
|
||||||
|
mock.patch("app.ai.requests.get", side_effect=fake_get), \
|
||||||
|
mock.patch(
|
||||||
|
"app.ai.socket.getaddrinfo",
|
||||||
|
return_value=[
|
||||||
|
(
|
||||||
|
socket.AF_INET,
|
||||||
|
socket.SOCK_STREAM,
|
||||||
|
6,
|
||||||
|
"",
|
||||||
|
("93.184.216.34", 443),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
):
|
||||||
|
result = ai.gen_cover(
|
||||||
|
"生成封面",
|
||||||
|
old_cover,
|
||||||
|
output,
|
||||||
|
resolution="1k",
|
||||||
|
config=cfg,
|
||||||
|
cmhub_config_path=key_path,
|
||||||
|
on_event=events.append,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(os.path.abspath(output), result)
|
||||||
|
payload = calls[0][2]["json"]
|
||||||
|
self.assertEqual("image-hd", payload["model"])
|
||||||
|
self.assertEqual("1K", payload["resolution"])
|
||||||
|
self.assertEqual("1:1", payload["aspect_ratio"])
|
||||||
|
self.assertTrue(payload["image_base64"].startswith("data:image/jpeg;base64,"))
|
||||||
|
self.assertEqual("https://cdn.example.com/generated.png", downloads[0][0])
|
||||||
|
self.assertEqual((3, 240), downloads[0][1]["timeout"])
|
||||||
|
self.assertEqual(91, events[0]["metadata"]["points_balance"])
|
||||||
|
with Image.open(output) as saved:
|
||||||
|
self.assertEqual((1024, 1024), saved.size)
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_cmhub_image_url_rejects_private_and_private_dns(self):
|
||||||
|
with self.assertRaises(ai.AIError):
|
||||||
|
ai._download_cmhub_image("http://127.0.0.1/a.png", 1, 1)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"app.ai.socket.getaddrinfo",
|
||||||
|
return_value=[
|
||||||
|
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.0.2", 443))
|
||||||
|
],
|
||||||
|
):
|
||||||
|
with self.assertRaises(ai.AIError):
|
||||||
|
ai._download_cmhub_image("https://cdn.example.com/a.png", 1, 1)
|
||||||
|
|
||||||
|
def test_cmhub_upstream_error_retries_and_keeps_metadata(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
cfg, key_path = self._cmhub_config(temp_dir)
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_request(method, url, **kwargs):
|
||||||
|
calls.append((method, url, kwargs))
|
||||||
|
if len(calls) == 1:
|
||||||
|
return _RequestsResponse(
|
||||||
|
{"error": {"code": "upstream_error", "message": "bad gateway"}},
|
||||||
|
status_code=502,
|
||||||
|
)
|
||||||
|
return _RequestsResponse({"titles": ["新标题"], "points_balance": 10})
|
||||||
|
|
||||||
|
with mock.patch("app.ai.requests.request", side_effect=fake_request), \
|
||||||
|
mock.patch("app.ai.time.sleep"):
|
||||||
|
title = ai.gen_title(
|
||||||
|
"prompt",
|
||||||
|
"old",
|
||||||
|
config=cfg,
|
||||||
|
cmhub_config_path=key_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("新标题", title)
|
||||||
|
self.assertEqual(2, len(calls))
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_cmhub_image_read_timeout_does_not_retry(self):
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
except ImportError:
|
||||||
|
self.skipTest("Pillow not installed")
|
||||||
|
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
cfg, key_path = self._cmhub_config(temp_dir)
|
||||||
|
old_cover = os.path.join(temp_dir, "old.jpg")
|
||||||
|
output = os.path.join(temp_dir, "new.jpg")
|
||||||
|
Image.new("RGB", (16, 16), (20, 30, 40)).save(old_cover, "JPEG")
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_request(method, url, **kwargs):
|
||||||
|
calls.append((method, url, kwargs))
|
||||||
|
raise ai.requests.exceptions.ReadTimeout("slow")
|
||||||
|
|
||||||
|
with mock.patch("app.ai.requests.request", side_effect=fake_request):
|
||||||
|
with self.assertRaises(ai.CMHubError) as raised:
|
||||||
|
ai.gen_cover(
|
||||||
|
"prompt",
|
||||||
|
old_cover,
|
||||||
|
output,
|
||||||
|
retry=3,
|
||||||
|
config=cfg,
|
||||||
|
cmhub_config_path=key_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("read_timeout", raised.exception.code)
|
||||||
|
self.assertEqual(1, len(calls))
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_fetch_cmhub_models_returns_aliases(self):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_request(method, url, **kwargs):
|
||||||
|
calls.append((method, url, kwargs))
|
||||||
|
return _RequestsResponse(
|
||||||
|
{
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"alias": "title-standard",
|
||||||
|
"operation_type": "title",
|
||||||
|
"requires_image": False,
|
||||||
|
"pricing_status": "priced",
|
||||||
|
"prices": [{"resolution": "512", "points_cost": 1}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch("app.ai.requests.request", side_effect=fake_request):
|
||||||
|
models = ai.fetch_cmhub_models("https://cmhub.example.com", "sk-cmhub-secret")
|
||||||
|
|
||||||
|
self.assertEqual("GET", calls[0][0])
|
||||||
|
self.assertEqual("https://cmhub.example.com/api/v1/models", calls[0][1])
|
||||||
|
self.assertEqual("title-standard", models[0]["alias"])
|
||||||
|
|
||||||
|
def test_generate_batch_forwards_cmhub_metadata_event(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
cfg, key_path = self._cmhub_config(temp_dir)
|
||||||
|
cfg["db_path"] = os.path.join(temp_dir, "cmshopee.db")
|
||||||
|
cfg["image_dir"] = os.path.join(temp_dir, "images")
|
||||||
|
batch_id, tasks = self._collected_tasks(temp_dir, cfg, ["旧标题A"])
|
||||||
|
events = []
|
||||||
|
|
||||||
|
def fake_request(method, url, **kwargs):
|
||||||
|
return _RequestsResponse(
|
||||||
|
{
|
||||||
|
"titles": ["新标题A"],
|
||||||
|
"alias": "title-standard",
|
||||||
|
"points_cost": 1,
|
||||||
|
"points_balance": 88,
|
||||||
|
"call_id": "call-batch-1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch("app.ai.requests.request", side_effect=fake_request):
|
||||||
|
summary = ai.generate_batch(
|
||||||
|
tasks,
|
||||||
|
{"title": "标题提示", "cover": "封面"},
|
||||||
|
ai_cfg={
|
||||||
|
"config": cfg,
|
||||||
|
"db_path": cfg["db_path"],
|
||||||
|
"cmhub_config_path": key_path,
|
||||||
|
"on_event": events.append,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(summary["ok"])
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
event.get("metadata", {}).get("points_balance") == 88
|
||||||
|
and event.get("metadata", {}).get("call_id") == "call-batch-1"
|
||||||
|
for event in events
|
||||||
|
)
|
||||||
|
)
|
||||||
|
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||||
|
self.assertEqual("generated", updated[0].stage)
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
def test_generate_batch_persists_titles_and_covers_per_task(self):
|
def test_generate_batch_persists_titles_and_covers_per_task(self):
|
||||||
with self.make_temp_dir() as temp_dir:
|
with self.make_temp_dir() as temp_dir:
|
||||||
cfg = self._config()
|
cfg = self._config()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
@@ -30,6 +31,35 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
|
|||||||
|
|
||||||
self.assert_removed(temp_dir)
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_cmhub_defaults_old_config_and_key_helper(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
config_path = os.path.join(temp_dir, "config.json")
|
||||||
|
cmhub_path = os.path.join(temp_dir, "config", "cmhub.json")
|
||||||
|
|
||||||
|
config = appconfig.load_config(config_path)
|
||||||
|
ai = appconfig.ai_config(config)
|
||||||
|
self.assertEqual("direct", ai["backend"])
|
||||||
|
self.assertEqual("direct", appconfig.ai_backend(config))
|
||||||
|
self.assertEqual("", appconfig.cmhub_config(config)["base_url"])
|
||||||
|
self.assertFalse(os.path.exists(cmhub_path))
|
||||||
|
self.assertEqual({"api_key": ""}, appconfig.load_cmhub_config(cmhub_path))
|
||||||
|
|
||||||
|
saved = appconfig.save_cmhub_config(
|
||||||
|
{"api_key": "sk-cmhub-123456"},
|
||||||
|
path=cmhub_path,
|
||||||
|
)
|
||||||
|
self.assertEqual("sk-cmhub-123456", saved["api_key"])
|
||||||
|
self.assertEqual("sk-cmhub-123456", appconfig.get_cmhub_api_key(cmhub_path))
|
||||||
|
self.assertEqual("sk-c***3456", appconfig.get_cmhub_api_key(cmhub_path, masked=True))
|
||||||
|
|
||||||
|
with open(config_path, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump({"ai": {"resolution": "512"}}, fh)
|
||||||
|
migrated = appconfig.load_config(config_path)
|
||||||
|
self.assertEqual("direct", appconfig.ai_config(migrated)["backend"])
|
||||||
|
self.assertEqual(180, appconfig.response_timeout(migrated))
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
def test_config_rejects_sensitive_fields(self):
|
def test_config_rejects_sensitive_fields(self):
|
||||||
with self.make_temp_dir() as temp_dir:
|
with self.make_temp_dir() as temp_dir:
|
||||||
config_path = os.path.join(temp_dir, "config.json")
|
config_path = os.path.join(temp_dir, "config.json")
|
||||||
|
|||||||
Reference in New Issue
Block a user