feat: 新增 Grok 图像节点、前端 UI 增强、重构 nano-banana 系列
- 新增 Grok Image 节点及客户端 - 新增 save_image_format 节点 - 新增前端 JS 扩展:画笔工具、点阵网格、侧边栏隐藏、资源切换、重命名等 - 重构 nano-banana 节点,移除 pro 版本 - 移除 multi_res_preview 节点 - 新增 http_error 工具模块 - 各客户端和节点优化改进 Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
@@ -21,6 +21,14 @@ DEFAULT_API_BASE_URL = "https://api.o1key.com"
|
||||
# 可通过环境变量 O1KEY_ASYNC_API_BASE_URL 覆盖
|
||||
DEFAULT_ASYNC_API_BASE_URL = "https://cf-api.o1key.com"
|
||||
|
||||
# ============ 网络线路配置 ============
|
||||
NETWORK_ROUTES = {
|
||||
"全球加速": "https://api.o1key.cn",
|
||||
"CF加速": "https://cf-api.o1key.com",
|
||||
"美国直连": "https://api.o1key.com",
|
||||
}
|
||||
NETWORK_ROUTE_OPTIONS = ["全球加速", "CF加速", "美国直连"]
|
||||
|
||||
|
||||
def load_config(config_path: Optional[str] = None) -> Dict[str, str]:
|
||||
"""
|
||||
@@ -136,3 +144,8 @@ def get_async_api_base_url() -> str:
|
||||
return base_url.rstrip('/')
|
||||
|
||||
return DEFAULT_ASYNC_API_BASE_URL
|
||||
|
||||
|
||||
def get_base_url_by_route(route: str) -> str:
|
||||
"""根据网络线路选项返回对应域名,未匹配则走 config 垫底"""
|
||||
return NETWORK_ROUTES.get(route, get_api_base_url())
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
统一 HTTP 错误处理 & 退避重试模块
|
||||
|
||||
使用方式:
|
||||
1. 对于 aiohttp 请求,用 async_request_with_retry() 包裹 POST/GET 调用
|
||||
2. 对于已拿到 status code 的场景,调用 raise_for_status() 抛出友好错误
|
||||
|
||||
新增生图/视频节点时,请统一使用本模块处理 HTTP 错误。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
from typing import Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 状态码 → 用户友好文案
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
HTTP_ERROR_MESSAGES = {
|
||||
429: "模型速率超限或额度不足!",
|
||||
502: "网关超时。请重试或将网络切换为美国直连",
|
||||
503: "模型超载。请稍后重试!",
|
||||
504: "网关超时。请稍后重试。",
|
||||
}
|
||||
|
||||
# 可退避重试的状态码
|
||||
RETRYABLE_STATUS_CODES = {429, 502, 503, 504}
|
||||
|
||||
# 退避重试默认参数
|
||||
DEFAULT_MAX_RETRIES = 3
|
||||
DEFAULT_BASE_DELAY = 2.0 # 首次重试等待秒数
|
||||
DEFAULT_MAX_DELAY = 30.0 # 最大等待秒数
|
||||
DEFAULT_BACKOFF_FACTOR = 2.0 # 指数退避因子
|
||||
|
||||
|
||||
def get_friendly_message(status_code: int, raw_message: str = "") -> str:
|
||||
"""根据状态码返回友好文案,未匹配则返回原始信息"""
|
||||
friendly = HTTP_ERROR_MESSAGES.get(status_code)
|
||||
if friendly:
|
||||
return friendly
|
||||
return raw_message or f"请求失败 ({status_code})"
|
||||
|
||||
|
||||
def raise_for_status(status_code: int, raw_message: str = "", prefix: str = ""):
|
||||
"""根据状态码抛出带友好文案的 RuntimeError"""
|
||||
friendly = get_friendly_message(status_code, raw_message)
|
||||
full_msg = f"{prefix}{friendly}" if prefix else friendly
|
||||
raise RuntimeError(full_msg)
|
||||
|
||||
|
||||
def is_retryable(status_code: int) -> bool:
|
||||
return status_code in RETRYABLE_STATUS_CODES
|
||||
|
||||
|
||||
def _compute_delay(attempt: int, base_delay: float, max_delay: float, backoff_factor: float) -> float:
|
||||
"""计算第 attempt 次重试的等待时间(含 jitter)"""
|
||||
delay = base_delay * (backoff_factor ** attempt)
|
||||
delay = min(delay, max_delay)
|
||||
jitter = random.uniform(0, delay * 0.3)
|
||||
return delay + jitter
|
||||
|
||||
|
||||
async def async_request_with_retry(
|
||||
session: aiohttp.ClientSession,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
max_retries: int = DEFAULT_MAX_RETRIES,
|
||||
base_delay: float = DEFAULT_BASE_DELAY,
|
||||
max_delay: float = DEFAULT_MAX_DELAY,
|
||||
backoff_factor: float = DEFAULT_BACKOFF_FACTOR,
|
||||
prefix: str = "",
|
||||
**request_kwargs,
|
||||
) -> aiohttp.ClientResponse:
|
||||
"""
|
||||
带退避重试的 aiohttp 请求。
|
||||
|
||||
仅对 RETRYABLE_STATUS_CODES (429/503/504) 进行重试。
|
||||
超过最大重试次数后抛出友好 RuntimeError。
|
||||
成功时返回 response 对象(调用者需在 async with 外自行处理 body)。
|
||||
|
||||
用法示例:
|
||||
resp = await async_request_with_retry(session, "POST", url, json=body, headers=headers)
|
||||
data = await resp.json()
|
||||
"""
|
||||
last_status: Optional[int] = None
|
||||
last_message = ""
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
resp = await session.request(method, url, **request_kwargs)
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
||||
if attempt < max_retries:
|
||||
delay = _compute_delay(attempt, base_delay, max_delay, backoff_factor)
|
||||
print(f"{prefix}网络错误,{delay:.1f}s 后重试 ({attempt+1}/{max_retries})...")
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
raise RuntimeError(f"{prefix}网络错误: {e}") from None
|
||||
|
||||
if resp.status == 200:
|
||||
return resp
|
||||
|
||||
last_status = resp.status
|
||||
try:
|
||||
last_message = await resp.text()
|
||||
except Exception:
|
||||
last_message = ""
|
||||
|
||||
if is_retryable(resp.status) and attempt < max_retries:
|
||||
delay = _compute_delay(attempt, base_delay, max_delay, backoff_factor)
|
||||
friendly = get_friendly_message(resp.status)
|
||||
print(f"{prefix}{friendly} {delay:.1f}s 后重试 ({attempt+1}/{max_retries})...")
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
|
||||
break
|
||||
|
||||
if last_status and last_status in HTTP_ERROR_MESSAGES:
|
||||
raise_for_status(last_status, prefix=prefix)
|
||||
|
||||
raw_msg = last_message[:200] if last_message else ""
|
||||
raise RuntimeError(f"{prefix}请求失败 ({last_status}): {raw_msg}")
|
||||
@@ -110,6 +110,57 @@ def encode_image_to_base64(image: Image.Image, format: str = "PNG") -> str:
|
||||
return base64.b64encode(img_bytes).decode('utf-8')
|
||||
|
||||
|
||||
_MAX_IMAGE_BYTES = 10 * 1024 * 1024 # 10MB base64 上限
|
||||
|
||||
|
||||
def encode_image_to_base64_limited(
|
||||
image: Image.Image,
|
||||
format: str = "PNG",
|
||||
max_bytes: int = _MAX_IMAGE_BYTES,
|
||||
) -> str:
|
||||
"""
|
||||
将 PIL Image 编码为 base64,若超过 max_bytes 则自动缩放直到满足限制。
|
||||
|
||||
策略:等比缩放,每轮缩小到上一轮的 80%,最多 10 轮。
|
||||
|
||||
Args:
|
||||
image: PIL Image 对象
|
||||
format: 图像格式,默认 PNG
|
||||
max_bytes: base64 字符串最大字节数,默认 10MB
|
||||
|
||||
Returns:
|
||||
base64 编码的字符串(保证 <= max_bytes)
|
||||
"""
|
||||
working = image
|
||||
if working.mode == 'RGBA':
|
||||
working = working.convert('RGB')
|
||||
|
||||
for attempt in range(10):
|
||||
buffered = BytesIO()
|
||||
working.save(buffered, format=format)
|
||||
b64 = base64.b64encode(buffered.getvalue()).decode('utf-8')
|
||||
|
||||
if len(b64) <= max_bytes:
|
||||
if attempt > 0:
|
||||
print(
|
||||
f"图片已自动缩放: {image.width}x{image.height} → "
|
||||
f"{working.width}x{working.height} "
|
||||
f"({len(b64) / 1024 / 1024:.2f}MB)"
|
||||
)
|
||||
return b64
|
||||
|
||||
# 缩放到 80%
|
||||
scale = 0.8
|
||||
new_w = max(1, int(working.width * scale))
|
||||
new_h = max(1, int(working.height * scale))
|
||||
working = working.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
||||
|
||||
# 兜底:返回最后一次编码结果
|
||||
buffered = BytesIO()
|
||||
working.save(buffered, format=format)
|
||||
return base64.b64encode(buffered.getvalue()).decode('utf-8')
|
||||
|
||||
|
||||
def decode_base64_to_pil(base64_string: str) -> Image.Image:
|
||||
"""
|
||||
将 base64 字符串解码为 PIL Image
|
||||
|
||||
Reference in New Issue
Block a user