From 67f137559c90d24ed5d3bdfdf35d98ed92370a78 Mon Sep 17 00:00:00 2001 From: tianmeng <702680355@qq.com> Date: Thu, 3 Sep 2026 19:47:06 +0800 Subject: [PATCH 1/5] feat: free vision quota, Seedream public image refs, drop closed Intelligence env Cap free users at 3 zero-credit vision tool uses; charge editElements; inline or rewrite localhost image URLs for Seedream; remove dead RECOMBYN_INTELLIGENCE_* remote env and mockup closed-service copy. --- apps/api/.env.example | 2 + apps/api/app/api/routes/image_process_jobs.py | 8 +- apps/api/app/api/routes/image_tools.py | 51 +- apps/api/app/core/config.py | 28 +- .../services/design/intelligence_runtime.py | 49 +- .../api/app/services/i18n/catalog/errors.json | 14 +- .../app/services/vision/mediakit_client.py | 20 +- .../app/services/vision/providers/seedream.py | 158 ++-- .../services/vision/providers/wavespeed.py | 92 ++- apps/api/app/services/vision/rehost.py | 116 ++- apps/api/app/services/wallet/db.py | 77 ++ .../design_engine/test_intelligence_client.py | 265 +------ .../test_design_canvas_crud.py | 4 - .../test_design_golden_paths.py | 4 - .../unit_tests/test_free_vision_quota.py | 63 ++ .../unit_tests/test_vision_image_refs.py | 95 +++ .../tests/unit_tests/test_wavespeed_vision.py | 4 +- .../nodes/ImageNode/ImageProcessWatcher.tsx | 728 +++++++++--------- .../nodes/ImageNode/UpscaleSessionHost.tsx | 4 +- .../toolPanels/ImageToolPanelHost.tsx | 17 +- apps/web/src/i18n/locales/en.ts | 4 +- apps/web/src/i18n/locales/ja.ts | 4 +- apps/web/src/i18n/locales/zh-CN.ts | 4 +- apps/web/src/i18n/locales/zh-TW.ts | 4 +- apps/web/src/service/imageTools.ts | 5 + apps/web/src/service/wallet.ts | 5 +- apps/web/src/styles/index.css | 2 - apps/web/src/utils/request.ts | 45 +- docker-compose.yml | 9 - scripts/dev-api.mjs | 2 - 30 files changed, 1002 insertions(+), 881 deletions(-) create mode 100644 apps/api/tests/unit_tests/test_free_vision_quota.py create mode 100644 apps/api/tests/unit_tests/test_vision_image_refs.py diff --git a/apps/api/.env.example b/apps/api/.env.example index 6ed545c6..1b3c5859 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -49,6 +49,8 @@ S3_ENABLED=false # VISION_EDIT_ELEMENTS_PROVIDER=seedream # VISION_SEEDREAM_LAYER_MODEL=doubao-seedream-5-0-pro-260628 # VISION_SEEDREAM_TIMEOUT_SEC=180 +# When local MinIO (localhost:9000), remote Seedream needs a public CDN base: +# VISION_PUBLIC_BASE_URL=https://files.recombyn.com/recombyn # DASHSCOPE_API_KEY= # BYTEPLUS_API_KEY= # TOPAZ_API_KEY= diff --git a/apps/api/app/api/routes/image_process_jobs.py b/apps/api/app/api/routes/image_process_jobs.py index bb6db7a2..4b837187 100644 --- a/apps/api/app/api/routes/image_process_jobs.py +++ b/apps/api/app/api/routes/image_process_jobs.py @@ -13,7 +13,12 @@ from app.api.deps import CurrentUser from app.api.routes.chat_job_sse import streaming_media_job_events -from app.api.routes.image_tools import ImageProcessIn, _charge, credit_cost_for_kind +from app.api.routes.image_tools import ( + ImageProcessIn, + _charge, + _require_free_vision_quota, + credit_cost_for_kind, +) from app.services.i18n.errors import http_error from app.services.i18n.locale import LocaleDep from app.services.job_store import get_job, normalize_trace_id, save_job, update_job @@ -167,6 +172,7 @@ async def create_image_process_job( raise http_error(400, "image_required", locale) cost = credit_cost_for_kind(tool_kind, body.model, user_id=current_user.id) + _require_free_vision_quota(current_user.id, cost=cost, locale=locale) _charge(current_user.id, cost, f"AI image tool: {tool_kind}", locale=locale) job_id = uuid.uuid4().hex diff --git a/apps/api/app/api/routes/image_tools.py b/apps/api/app/api/routes/image_tools.py index b54c2c3f..0e586b86 100644 --- a/apps/api/app/api/routes/image_tools.py +++ b/apps/api/app/api/routes/image_tools.py @@ -18,13 +18,22 @@ uses_llm_for_kind, ) from app.services.wallet.billing import DEFAULT_IMAGE_CREDITS, image_model_credit_cost -from app.services.wallet.db import is_wallet_billing_enabled, spend_credits +from app.services.wallet.db import ( + consume_free_vision_quota, + free_vision_remaining, + get_user_plan, + is_wallet_billing_enabled, + spend_credits, +) router = APIRouter(prefix="/image", tags=["image-tools"]) -# Wallet 积分 for LLM image tools only (Seedream i2i). MediaKit / WaveSpeed / layered → 0. +# Wallet 积分 for paid vision / LLM image paths. +# MediaKit tools (removeBg / expand / …) stay 0 — free users use freeVision quota. _KIND_CREDIT_COST: dict[str, int] = { "replaceText": 30, + # Seedream layer_decomposition / WaveSpeed qwen-image/layered + "editElements": 30, } @@ -49,28 +58,46 @@ def _charge(user_id: str, amount: int, detail: str, *, locale: str | None = None raise http_error(400, "request_failed", locale) from err +def _require_free_vision_quota( + user_id: str, + *, + cost: int, + locale: str | None = None, +) -> None: + """Free plan: lifetime cap on zero-credit vision toolbar tools.""" + if cost > 0 or not is_wallet_billing_enabled(): + return + if str(get_user_plan(user_id) or "free").strip().lower() != "free": + return + if consume_free_vision_quota(user_id): + return + raise http_error(402, "free_vision_exhausted", locale) + + def credit_cost_for_kind( kind: str, model: str | None = None, *, user_id: str | None = None, ) -> int: - """Platform credits only when an LLM image path runs on the platform key.""" + """Platform credits for paid image tools (LLM i2i + Seedream/WaveSpeed layered).""" k = (kind or "").strip() - if not uses_llm_for_kind(k): - return 0 if not is_wallet_billing_enabled(): return 0 if is_byok_model_ref(model) or (user_id and uses_user_platform_byok(user_id, model)): return 0 + if k in _KIND_CREDIT_COST: + return int(_KIND_CREDIT_COST[k]) + if not uses_llm_for_kind(k): + return 0 mid = (model or "").strip() if mid: return image_model_credit_cost(mid) - return int(_KIND_CREDIT_COST.get(k, DEFAULT_IMAGE_CREDITS)) + return int(DEFAULT_IMAGE_CREDITS) @router.get("/tools") -def list_image_tools() -> dict[str, Any]: +def list_image_tools(current_user: CurrentUser) -> dict[str, Any]: from app.core.config import settings from app.services.llm.image_tools import ( mediakit_supports, @@ -82,6 +109,7 @@ def list_image_tools() -> dict[str, Any]: seedream_enabled, wavespeed_enabled, ) + from app.services.wallet.db import FREE_VISION_LIMIT kinds = sorted(IMAGE_PROCESS_KINDS) costs = {k: credit_cost_for_kind(k) for k in kinds} @@ -91,6 +119,10 @@ def list_image_tools() -> dict[str, Any]: dash_on = bool(str(settings.dashscope_api_key or "").strip()) byte_on = bool(str(settings.byteplus_api_key or "").strip()) topaz_on = bool(str(settings.topaz_api_key or "").strip()) + plan = str(get_user_plan(current_user.id) or "free").strip().lower() + vision_remaining = None + if is_wallet_billing_enabled() and plan == "free": + vision_remaining = free_vision_remaining(current_user.id) return { "kinds": kinds, "credits": costs, @@ -119,6 +151,10 @@ def list_image_tools() -> dict[str, Any]: }, # FE mockup is client-side only (no server bake API). "mockup": {"enabled": True, "templates": []}, + "freeVision": { + "limit": FREE_VISION_LIMIT, + "remaining": vision_remaining, + }, } @@ -130,6 +166,7 @@ async def post_image_process( ) -> dict[str, Any]: kind = body.kind.strip() cost = credit_cost_for_kind(kind, body.model, user_id=current_user.id) + _require_free_vision_quota(current_user.id, cost=cost, locale=locale) _charge(current_user.id, cost, f"AI image tool: {kind}", locale=locale) try: diff --git a/apps/api/app/core/config.py b/apps/api/app/core/config.py index 33169d50..47320e28 100644 --- a/apps/api/app/core/config.py +++ b/apps/api/app/core/config.py @@ -156,28 +156,6 @@ class Settings(BaseSettings): agent_profile_id: str = "design.canvas" - # Design agent floors (ADR 0017). Always BasicLocal in-process — no remote Intelligence service. - intelligence_provider: str = Field( - default="local", - validation_alias="RECOMBYN_INTELLIGENCE_MODE", - ) - intelligence_remote_url: str = Field( - default="", - validation_alias="RECOMBYN_INTELLIGENCE_URL", - ) - intelligence_remote_api_key: str = Field( - default="", - validation_alias="RECOMBYN_INTELLIGENCE_API_KEY", - ) - intelligence_remote_timeout_sec: float = Field( - default=30.0, - validation_alias="RECOMBYN_INTELLIGENCE_TIMEOUT_SEC", - ) - intelligence_circuit_sec: float = Field( - default=30.0, - validation_alias="RECOMBYN_INTELLIGENCE_CIRCUIT_SEC", - ) - # Volcengine AI MediaKit (OSS: removeBg, expand, editText, eraser, upscale, translateImage, productScene). mediakit_api_key: str = Field( default="", @@ -221,6 +199,12 @@ class Settings(BaseSettings): default=180.0, validation_alias="VISION_SEEDREAM_TIMEOUT_SEC", ) + # When S3_PUBLIC_BASE_URL is localhost/MinIO, remote APIs (Seedream/WaveSpeed) + # need a reachable CDN base (prod: https://files.recombyn.com/recombyn). + vision_public_base_url: str = Field( + default="", + validation_alias="VISION_PUBLIC_BASE_URL", + ) # Reserved for later vision vendors (keys only — no layered clients yet). dashscope_api_key: str = Field(default="", validation_alias="DASHSCOPE_API_KEY") diff --git a/apps/api/app/services/design/intelligence_runtime.py b/apps/api/app/services/design/intelligence_runtime.py index 64463dbc..ebee9c6f 100644 --- a/apps/api/app/services/design/intelligence_runtime.py +++ b/apps/api/app/services/design/intelligence_runtime.py @@ -444,57 +444,22 @@ def reset_design_intelligence_client() -> None: _client = None def remote_billing_base_url() -> str: - """No remote Intelligence host — billing quotes stay local.""" + """No remote Intelligence billing host in the open tree.""" return "" -def _remote_billing_headers() -> dict[str, str]: - key = str(getattr(settings, "intelligence_remote_api_key", "") or "").strip() - headers = {"Content-Type": "application/json"} - if key: - headers["Authorization"] = f"Bearer {key}" - return headers - - def call_remote_billing( method: str, path: str, *, json_body: dict[str, Any] | None = None, ) -> dict[str, Any] | None: - """HTTP call to optional host ``/billing/quote``. Returns None if unavailable.""" - import urllib.error - import urllib.request - - base = remote_billing_base_url() - if not base: - return None - url = f"{base}{path}" - timeout = float(getattr(settings, "intelligence_remote_timeout_sec", 30.0) or 30.0) - data = None - headers = _remote_billing_headers() - if json_body is not None or method.upper() in ("POST", "PUT"): - import json as _json - - raw = _json.dumps(json_body or {}).encode("utf-8") - data = raw - req = urllib.request.Request( - url, - data=data, - headers=headers, - method=method.upper(), - ) - try: - with urllib.request.urlopen(req, timeout=timeout) as resp: - import json as _json - - body = _json.loads(resp.read().decode("utf-8")) - return body if isinstance(body, dict) else None - except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ValueError) as e: - _log.warning("remote billing %s %s failed: %s", method, path, e) - return None + """Optional remote billing — always unavailable (BasicLocal only).""" + _ = method, path, json_body + return None def quote_remote_task_credits(body: dict[str, Any]) -> dict[str, Any] | None: - """Optional host credit quote — wire returns credits only.""" - return call_remote_billing("POST", "/billing/quote", json_body=body) + """Optional host credit quote — not wired in the open tree.""" + _ = body + return None diff --git a/apps/api/app/services/i18n/catalog/errors.json b/apps/api/app/services/i18n/catalog/errors.json index e031ebf6..586b124d 100644 --- a/apps/api/app/services/i18n/catalog/errors.json +++ b/apps/api/app/services/i18n/catalog/errors.json @@ -5,6 +5,12 @@ "en": "Daily free run used up. Try again tomorrow or upgrade.", "ja": "本日の無料実行回数を使い切りました。明日再度お試しいただくか、プランをアップグレードしてください。" }, + "free_vision_exhausted": { + "zh-CN": "免费用户抠图等视觉工具最多使用 3 次,次数已用完,请升级会员后继续使用。", + "zh-TW": "免費用戶去背等視覺工具最多使用 3 次,次數已用完,請升級會員後繼續使用。", + "en": "Free plan includes 3 vision tool uses (remove background, etc.). Limit reached — upgrade to continue.", + "ja": "無料プランの画像ツール(背景除去など)は 3 回までです。上限に達しました。続行するにはプランをアップグレードしてください。" + }, "insufficient_credits": { "zh-CN": "积分不足,请充值后重试。", "zh-TW": "積分不足,請充值後重試。", @@ -672,10 +678,10 @@ "ja": "DLQ エントリに project_id/user_id がありません — 再生できません。" }, "mockup_unavailable": { - "zh-CN": "样机渲染需要接入 Recombyn Intelligence(设置 RECOMBYN_INTELLIGENCE_URL)", - "zh-TW": "樣機渲染需要接入 Recombyn Intelligence(設定 RECOMBYN_INTELLIGENCE_URL)", - "en": "Mockup rendering requires Recombyn Intelligence (set RECOMBYN_INTELLIGENCE_URL).", - "ja": "モックアップ描画には Recombyn Intelligence が必要です(RECOMBYN_INTELLIGENCE_URL を設定してください)。" + "zh-CN": "样机预览失败,请稍后重试。", + "zh-TW": "樣機預覽失敗,請稍後再試。", + "en": "Mockup preview failed. Please try again.", + "ja": "モックアップのプレビューに失敗しました。もう一度お試しください。" }, "card_key_misconfigured": { "zh-CN": "卡密系统未正确配置,请联系管理员", diff --git a/apps/api/app/services/vision/mediakit_client.py b/apps/api/app/services/vision/mediakit_client.py index 21ea0fa8..5a787248 100644 --- a/apps/api/app/services/vision/mediakit_client.py +++ b/apps/api/app/services/vision/mediakit_client.py @@ -234,23 +234,9 @@ async def _load_image_bytes(image_ref: str) -> tuple[bytes, str]: def _is_public_http_url(ref: str) -> bool: - s = (ref or "").strip() - if not (s.startswith("http://") or s.startswith("https://")): - return False - try: - host = (urlparse(s).hostname or "").lower() - except Exception: - return False - if not host: - return False - if host in {"localhost", "127.0.0.1", "0.0.0.0", "::1"}: - return False - if host.endswith(".local") or host.endswith(".internal"): - return False - if host.startswith("10.") or host.startswith("192.168.") or host.startswith("172."): - # Private LAN — MediaKit cannot fetch. - return False - return True + from app.services.vision.rehost import is_public_http_url + + return is_public_http_url(ref) def _parse_upload_headers(raw: Any) -> dict[str, str]: diff --git a/apps/api/app/services/vision/providers/seedream.py b/apps/api/app/services/vision/providers/seedream.py index cc364997..e5674ac2 100644 --- a/apps/api/app/services/vision/providers/seedream.py +++ b/apps/api/app/services/vision/providers/seedream.py @@ -7,18 +7,21 @@ import base64 import io -import logging import re from typing import Any -from urllib.parse import urlparse import httpx from PIL import Image from app.core.config import settings from app.services.vision.providers.base import ProgressCb - -logger = logging.getLogger(__name__) +from app.services.vision.rehost import ( + bytes_to_data_url, + ipv4_loopback_url, + is_http_url, + is_public_http_url, + rewrite_private_storage_url, +) _DATA_URL_RE = re.compile(r"^data:([^;,]+)?(?:;base64)?,(.+)$", re.DOTALL) _DEFAULT_MODEL = "doubao-seedream-5-0-pro-260628" @@ -28,6 +31,7 @@ "见 https://console.volcengine.com/ark)" ) _TIMEOUT_SEC = 180.0 +_SIZE_PRESETS = frozenset({"1K", "1.5K", "2K"}) def seedream_enabled() -> bool: @@ -52,22 +56,12 @@ def _require_enabled() -> None: raise RuntimeError(_SEEDREAM_REQUIRED_MSG) -def _is_http_url(ref: str) -> bool: - try: - parsed = urlparse(ref) - except Exception: - return False - return parsed.scheme in ("http", "https") and bool(parsed.netloc) - - -async def _ensure_image_ref(image: str, *, user_id: str | None) -> str: - """Ark accepts public URL or data URL; rehost private refs when needed.""" - ref = (image or "").strip() - if not ref: - raise ValueError("image is required") - if _is_http_url(ref) or ref.startswith("data:"): - return ref - raise ValueError("image must be a data URL or http(s) URL") +def _normalize_size(raw: Any) -> str: + s = str(raw or "auto").strip() or "auto" + upper = s.upper() + if upper in _SIZE_PRESETS: + return upper + return s async def _download(url_or_data: str) -> bytes: @@ -80,22 +74,41 @@ async def _download(url_or_data: str) -> bytes: raise RuntimeError("invalid data URL from Seedream") return base64.b64decode(match.group(2)) async with httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=20.0)) as client: - resp = await client.get(ref) + resp = await client.get(ipv4_loopback_url(ref)) resp.raise_for_status() return resp.content +async def _ensure_image_ref(image: str, *, user_id: str | None) -> str: + """Give Ark a public URL or data URL — never localhost / LAN.""" + _ = user_id + ref = (image or "").strip() + if not ref: + raise ValueError("image is required") + if ref.startswith("data:"): + return ref + if not is_http_url(ref): + raise ValueError("image must be a data URL or http(s) URL") + if is_public_http_url(ref): + return ref + rewritten = rewrite_private_storage_url(ref) + if rewritten: + return rewritten + return bytes_to_data_url(await _download(ref)) + + def _parse_size_wh(raw: Any) -> tuple[int, int]: s = str(raw or "").strip().lower() - if "x" in s: - parts = s.split("x", 1) - try: - w, h = int(parts[0]), int(parts[1]) - if w > 0 and h > 0: - return w, h - except ValueError: - pass - return 0, 0 + if "x" not in s: + return 0, 0 + left, right = s.split("x", 1) + try: + w, h = int(left), int(right) + except ValueError: + return 0, 0 + if w <= 0 or h <= 0: + return 0, 0 + return w, h def _bbox_xywh(item: dict[str, Any]) -> tuple[float, float, float, float] | None: @@ -106,12 +119,7 @@ def _bbox_xywh(item: dict[str, Any]) -> tuple[float, float, float, float] | None if not isinstance(absolute, (list, tuple)) or len(absolute) < 4: return None try: - left, top, right, bottom = ( - float(absolute[0]), - float(absolute[1]), - float(absolute[2]), - float(absolute[3]), - ) + left, top, right, bottom = (float(absolute[i]) for i in range(4)) except (TypeError, ValueError): return None w = max(0.0, right - left) @@ -131,6 +139,15 @@ def _item_url(item: dict[str, Any]) -> str: return "" +def _layer_display_name(raw: dict[str, Any], z: int) -> str: + name = str(raw.get("name") or "").strip() + if name: + return name + if z == 0: + return "Background" + return f"Layer {z}" + + def map_seedream_data_to_layers( data_items: list[dict[str, Any]], ) -> list[dict[str, Any]]: @@ -140,20 +157,18 @@ def map_seedream_data_to_layers( if not isinstance(raw, dict): continue try: - z = int(raw.get("z_index") if raw.get("z_index") is not None else i) + z_raw = raw.get("z_index") + z = int(z_raw) if z_raw is not None else i except (TypeError, ValueError): z = i url = _item_url(raw) if not url: continue - name = str(raw.get("name") or "").strip() or ( - "Background" if z == 0 else f"Layer {z}" - ) rows.append( { "z_index": z, "url": url, - "name": name, + "name": _layer_display_name(raw, z), "description": str(raw.get("description") or "").strip(), "size": raw.get("size"), "bounding_box": raw.get("bounding_box"), @@ -163,6 +178,38 @@ def map_seedream_data_to_layers( return rows +def _raster_wh(raw: bytes) -> tuple[int, int]: + with Image.open(io.BytesIO(raw)) as img: + return int(img.width), int(img.height) + + +def _layer_geometry( + row: dict[str, Any], + raw_bytes: bytes, + *, + canvas_w: int, + canvas_h: int, +) -> tuple[float, float, float, float, int, int]: + """Return (x, y, w, h, canvas_w, canvas_h) for one Seedream layer.""" + z = int(row["z_index"]) + size_w, size_h = _parse_size_wh(row.get("size")) + xywh = _bbox_xywh(row) + + if z == 0: + if size_w > 0 and size_h > 0: + cw, ch = size_w, size_h + else: + cw, ch = _raster_wh(raw_bytes) + return 0.0, 0.0, float(cw), float(ch), cw, ch + + if xywh: + x, y, w, h = xywh + return x, y, w, h, canvas_w, canvas_h + + nw, nh = _raster_wh(raw_bytes) + return 0.0, 0.0, float(nw), float(nh), canvas_w, canvas_h + + async def run_layered( image: str, *, @@ -180,9 +227,7 @@ async def run_layered( m = meta or {} image_ref = await _ensure_image_ref(image, user_id=user_id) - size = str(m.get("size") or m.get("resolution") or "auto").strip() or "auto" - if size.upper() in ("1K", "1.5K", "2K"): - size = size.upper() if size.upper() != "1.5K" else "1.5K" + size = _normalize_size(m.get("size") or m.get("resolution")) prompt = str(m.get("prompt") or "").strip() model = layer_model_id() @@ -224,9 +269,7 @@ async def run_layered( if not isinstance(data, list) or not data: raise RuntimeError("Seedream layer_decomposition returned no data") - mapped = map_seedream_data_to_layers( - [x for x in data if isinstance(x, dict)] - ) + mapped = map_seedream_data_to_layers([x for x in data if isinstance(x, dict)]) if not mapped: raise RuntimeError("Seedream layer_decomposition returned empty layers") @@ -237,22 +280,9 @@ async def run_layered( layers_out: list[dict[str, Any]] = [] for row in mapped: raw_bytes = await _download(str(row["url"])) - z = int(row["z_index"]) - xywh = _bbox_xywh(row) - size_w, size_h = _parse_size_wh(row.get("size")) - if z == 0: - if size_w > 0 and size_h > 0: - canvas_w, canvas_h = size_w, size_h - else: - with Image.open(io.BytesIO(raw_bytes)) as img: - canvas_w, canvas_h = int(img.width), int(img.height) - x, y, w, h = 0.0, 0.0, float(canvas_w), float(canvas_h) - elif xywh: - x, y, w, h = xywh - else: - with Image.open(io.BytesIO(raw_bytes)) as img: - nw, nh = int(img.width), int(img.height) - x, y, w, h = 0.0, 0.0, float(nw), float(nh) + x, y, w, h, canvas_w, canvas_h = _layer_geometry( + row, raw_bytes, canvas_w=canvas_w, canvas_h=canvas_h + ) layers_out.append( { "bytes": raw_bytes, @@ -261,7 +291,7 @@ async def run_layered( "width": w, "height": h, "name": str(row["name"]), - "z_index": z, + "z_index": int(row["z_index"]), "description": str(row.get("description") or ""), } ) diff --git a/apps/api/app/services/vision/providers/wavespeed.py b/apps/api/app/services/vision/providers/wavespeed.py index d193aa6d..805e20de 100644 --- a/apps/api/app/services/vision/providers/wavespeed.py +++ b/apps/api/app/services/vision/providers/wavespeed.py @@ -10,20 +10,22 @@ import asyncio import base64 import io -import logging import re import time from typing import Any -from urllib.parse import urlparse import httpx from PIL import Image from app.core.config import settings from app.services.vision.providers.base import ProgressCb -from app.services.vision.rehost import rehost_image_bytes - -logger = logging.getLogger(__name__) +from app.services.vision.rehost import ( + bytes_to_data_url, + ipv4_loopback_url, + is_http_url, + is_public_http_url, + rehost_image_bytes, +) _DATA_URL_RE = re.compile(r"^data:([^;,]+)?(?:;base64)?,(.+)$", re.DOTALL) _TERMINAL_FAIL = frozenset({"failed", "cancelled", "timeout", "deleted"}) @@ -76,12 +78,11 @@ def _unwrap_data(payload: Any) -> dict[str, Any]: return payload -def _is_http_url(ref: str) -> bool: - try: - parsed = urlparse(ref) - except Exception: - return False - return parsed.scheme in ("http", "https") and bool(parsed.netloc) +def _outputs_list(task: dict[str, Any]) -> list[str] | None: + outputs = task.get("outputs") + if not isinstance(outputs, list) or not outputs: + return None + return [str(x) for x in outputs if str(x).strip()] async def _load_image_bytes(image_ref: str) -> tuple[bytes, str]: @@ -93,15 +94,15 @@ async def _load_image_bytes(image_ref: str) -> tuple[bytes, str]: if not match: raise ValueError("invalid data URL") return base64.b64decode(match.group(2)), "image/png" - if _is_http_url(ref): - async with httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=20.0)) as client: - resp = await client.get(ref) - resp.raise_for_status() - ctype = (resp.headers.get("content-type") or "image/png").split(";")[0].strip() - if not ctype.startswith("image/"): - ctype = "image/png" - return resp.content, ctype - raise ValueError("image must be a data URL or http(s) URL") + if not is_http_url(ref): + raise ValueError("image must be a data URL or http(s) URL") + async with httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=20.0)) as client: + resp = await client.get(ipv4_loopback_url(ref)) + resp.raise_for_status() + ctype = (resp.headers.get("content-type") or "image/png").split(";")[0].strip() + if not ctype.startswith("image/"): + ctype = "image/png" + return resp.content, ctype async def ensure_public_image_url( @@ -110,25 +111,23 @@ async def ensure_public_image_url( user_id: str | None, filename: str = "wavespeed-input.png", ) -> str: - """WaveSpeed fetches remote URLs; rehost data / private refs when needed.""" + """WaveSpeed fetches remote URLs; inline private / loopback refs as data URLs.""" ref = (image or "").strip() if not ref: raise ValueError("image is required") - if _is_http_url(ref): + if is_http_url(ref) and is_public_http_url(ref): return ref raw, ctype = await _load_image_bytes(ref) uid = str(user_id or "").strip() - if not uid: - # Fallback: WaveSpeed accepts some data URLs; prefer rehost in production. - b64 = base64.b64encode(raw).decode("ascii") - return f"data:{ctype};base64,{b64}" - ext = "png" if "png" in ctype else "jpg" - return rehost_image_bytes( - uid, - raw, - filename=filename if filename.endswith(ext) else f"wavespeed-input.{ext}", - content_type=ctype or "image/png", - ) + if uid: + ext = "png" if "png" in ctype else "jpg" + name = filename if filename.endswith(ext) else f"wavespeed-input.{ext}" + hosted = rehost_image_bytes( + uid, raw, filename=name, content_type=ctype or "image/png" + ) + if is_public_http_url(hosted): + return hosted + return bytes_to_data_url(raw, content_type=ctype) async def _download_output(url_or_b64: str) -> bytes: @@ -141,7 +140,7 @@ async def _download_output(url_or_b64: str) -> bytes: raise RuntimeError("invalid WaveSpeed data URL output") return base64.b64decode(match.group(2)) # Naked base64 (enable_base64_output) - if not _is_http_url(ref) and len(ref) > 64 and re.fullmatch(r"[A-Za-z0-9+/=\s]+", ref or ""): + if not is_http_url(ref) and len(ref) > 64 and re.fullmatch(r"[A-Za-z0-9+/=\s]+", ref): try: return base64.b64decode(ref) except Exception: @@ -178,17 +177,15 @@ async def submit_and_wait( task = _unwrap_data(submit_resp.json()) prediction_id = str(task.get("id") or "").strip() if not prediction_id: - # Sync mode may return outputs immediately. - outputs = task.get("outputs") - if isinstance(outputs, list) and outputs: - return [str(x) for x in outputs if str(x).strip()] + sync_out = _outputs_list(task) + if sync_out: + return sync_out raise RuntimeError("WaveSpeed submit response missing prediction id") - status = str(task.get("status") or "").strip().lower() - if status == "completed": - outputs = task.get("outputs") - if isinstance(outputs, list) and outputs: - return [str(x) for x in outputs if str(x).strip()] + if str(task.get("status") or "").strip().lower() == "completed": + done = _outputs_list(task) + if done: + return done result_url = f"{_base_url()}/api/v3/predictions/{prediction_id}/result" poll_n = 0 @@ -198,8 +195,7 @@ async def submit_and_wait( await asyncio.sleep(_POLL_SEC) poll_n += 1 if on_progress: - pct = min(90, 10 + poll_n * 5) - on_progress(pct, "wavespeed:poll") + on_progress(min(90, 10 + poll_n * 5), "wavespeed:poll") poll_resp = await client.get(result_url, headers=headers) if poll_resp.status_code >= 400: detail = (poll_resp.text or "")[:400] @@ -207,10 +203,10 @@ async def submit_and_wait( result = _unwrap_data(poll_resp.json()) status = str(result.get("status") or "").strip().lower() if status == "completed": - outputs = result.get("outputs") - if not isinstance(outputs, list) or not outputs: + done = _outputs_list(result) + if not done: raise RuntimeError("WaveSpeed completed with empty outputs") - return [str(x) for x in outputs if str(x).strip()] + return done if status in _TERMINAL_FAIL: err = str(result.get("error") or status) raise RuntimeError(f"WaveSpeed prediction {status}: {err}") diff --git a/apps/api/app/services/vision/rehost.py b/apps/api/app/services/vision/rehost.py index f764875a..ac95727c 100644 --- a/apps/api/app/services/vision/rehost.py +++ b/apps/api/app/services/vision/rehost.py @@ -1,7 +1,121 @@ -"""Rehost vision tool outputs to our uploads store (avoid giant data-URL round-trips).""" +"""Vision image hosting + URL helpers for remote APIs (Seedream / WaveSpeed / MediaKit).""" from __future__ import annotations +import base64 +from urllib.parse import urlparse + +from app.core.config import settings + +_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "0.0.0.0", "::1"}) + + +def is_http_url(ref: str) -> bool: + try: + parsed = urlparse(ref) + except Exception: + return False + return parsed.scheme in ("http", "https") and bool(parsed.netloc) + + +def _host_is_private_lan(host: str) -> bool: + if host.startswith("10.") or host.startswith("192.168."): + return True + if not host.startswith("172."): + return False + parts = host.split(".") + if len(parts) < 2: + return False + try: + second = int(parts[1]) + except ValueError: + return False + return 16 <= second <= 31 + + +def is_public_http_url(ref: str) -> bool: + """True when a remote cloud API can fetch the URL itself.""" + if not is_http_url(ref): + return False + try: + host = (urlparse(ref).hostname or "").lower() + except Exception: + return False + if not host or host in _LOOPBACK_HOSTS: + return False + if host.endswith(".local") or host.endswith(".internal"): + return False + if _host_is_private_lan(host): + return False + return True + + +def ipv4_loopback_url(ref: str) -> str: + """Rewrite localhost / ::1 → 127.0.0.1 so local MinIO fetches work on Windows.""" + try: + parsed = urlparse(ref) + host = (parsed.hostname or "").lower() + if host not in {"localhost", "::1"}: + return ref + port = f":{parsed.port}" if parsed.port else "" + out = f"{parsed.scheme}://127.0.0.1{port}{parsed.path or ''}" + if parsed.query: + return f"{out}?{parsed.query}" + return out + except Exception: + return ref + + +def bytes_to_data_url(raw: bytes, *, content_type: str = "image/png") -> str: + ctype = (content_type or "image/png").split(";")[0].strip() or "image/png" + if not ctype.startswith("image/"): + ctype = "image/png" + return f"data:{ctype};base64,{base64.b64encode(raw).decode('ascii')}" + + +def storage_object_key(ref: str, *, local_base: str = "") -> str: + """Extract object key from a storage display URL.""" + src = (ref or "").strip() + base = (local_base or "").strip().rstrip("/") + if base and src.startswith(base + "/"): + return src[len(base) + 1 :].split("?", 1)[0].lstrip("/") + try: + path = urlparse(src).path or "" + except Exception: + return "" + # path-style MinIO: /{bucket}/{key…} + parts = [p for p in path.split("/") if p] + if len(parts) < 2: + return "" + return "/".join(parts[1:]) + + +def rewrite_private_storage_url(ref: str) -> str | None: + """ + Map local MinIO URL → public CDN (VISION_PUBLIC_BASE_URL). + + Example: + http://localhost:9000/recombyn/uploads/a.png + → https://files.recombyn.com/recombyn/uploads/a.png + """ + src = (ref or "").strip() + if not src or not is_http_url(src): + return None + if is_public_http_url(src): + return src + + local_base = str(settings.s3_public_base_url or "").strip().rstrip("/") + public_base = str(settings.vision_public_base_url or "").strip().rstrip("/") + if not public_base and local_base and is_public_http_url(local_base): + public_base = local_base + if not public_base or not is_public_http_url(public_base): + return None + + key = storage_object_key(src, local_base=local_base) + if not key: + return None + return f"{public_base}/{key}" + def rehost_image_bytes( user_id: str | None, diff --git a/apps/api/app/services/wallet/db.py b/apps/api/app/services/wallet/db.py index f2b1c780..45cfa3dc 100644 --- a/apps/api/app/services/wallet/db.py +++ b/apps/api/app/services/wallet/db.py @@ -67,6 +67,9 @@ def ensure_credits_scale_x10_migration() -> None: "FREE_DAILY_LIMIT", "free_daily_remaining", "consume_free_daily_quota", + "FREE_VISION_LIMIT", + "free_vision_remaining", + "consume_free_vision_quota", ] @@ -330,6 +333,80 @@ def consume_free_daily_quota(user_id: str, *, limit: int = FREE_DAILY_LIMIT) -> return True +# Free users: lifetime cap on zero-credit vision toolbar tools (抠图 / 放大 / …). +FREE_VISION_LIMIT = 3 +_FREE_VISION_PREFIX = "free_vision:" + + +def free_vision_remaining(user_id: str, *, limit: int = FREE_VISION_LIMIT) -> int: + """How many free vision tool uses remain (does not consume).""" + from sqlmodel import Session + + from app import crud + from app.core.db import engine + + init_schema() + uid = (user_id or "").strip() + lim = max(0, int(limit if limit is not None else FREE_VISION_LIMIT)) + if not uid or lim <= 0: + return 0 + with Session(engine) as session: + used = crud.count_wallet_ledger_detail_prefix( + session=session, user_id=uid, detail_prefix=_FREE_VISION_PREFIX + ) + return max(0, lim - used) + + +def consume_free_vision_quota(user_id: str, *, limit: int = FREE_VISION_LIMIT) -> bool: + """ + Atomically reserve one free vision tool use for plan=free. + Returns True if reserved; False if the lifetime quota is already used. + Writes a zero-amount ledger marker (does not change balance). + """ + from sqlmodel import Session + + from app import crud + from app.core.db import engine + from app.models import UserBalance + + init_schema() + uid = (user_id or "").strip() + lim = max(0, int(limit if limit is not None else FREE_VISION_LIMIT)) + if not uid or lim <= 0: + return False + import time + + now = time.time() + with Session(engine) as session: + used = crud.count_wallet_ledger_detail_prefix( + session=session, user_id=uid, detail_prefix=_FREE_VISION_PREFIX + ) + if used >= lim: + return False + bal = crud.get_user_balance(session=session, user_id=uid) + if not bal: + bal = UserBalance( + user_id=uid, + credits=0, + plan_id="free", + plan_expires_at=None, + updated_at=now, + ) + session.add(bal) + session.flush() + crud.add_wallet_ledger( + session=session, + user_id=uid, + kind="spend", + amount=0, + balance_after=int(bal.credits or 0), + detail=f"{_FREE_VISION_PREFIX}use", + commit=False, + ) + session.commit() + return True + + def spend_credits( user_id: str, amount: int, diff --git a/apps/api/tests/design_engine/test_intelligence_client.py b/apps/api/tests/design_engine/test_intelligence_client.py index 451f7ad5..835e6af1 100644 --- a/apps/api/tests/design_engine/test_intelligence_client.py +++ b/apps/api/tests/design_engine/test_intelligence_client.py @@ -47,22 +47,20 @@ def test_basic_local_is_intelligence_provider(): assert isinstance(BasicLocalProvider(), IntelligenceProvider) -def test_factory_local_uses_basic_local(monkeypatch): +def test_factory_local_uses_basic_local(): from app.services.design import intelligence_runtime as ir - monkeypatch.setattr(ir.settings, "intelligence_provider", "local") ir.reset_design_intelligence_client() client = ir.build_design_intelligence_client() assert isinstance(client, DesignIntelligenceClient) assert isinstance(client.provider, BasicLocalProvider) -def test_client_research_via_basic_local(monkeypatch): +def test_client_research_via_basic_local(): import asyncio from app.services.design import intelligence_runtime as ir - monkeypatch.setattr(ir.settings, "intelligence_provider", "local") ir.reset_design_intelligence_client() client = ir.build_design_intelligence_client() rt = _rt() @@ -95,12 +93,11 @@ def test_remote_empty_dict_not_usable(): assert remote_result_usable("research", {"summary": "ok", "provider": "x"}) -def test_stable_client_surface_uses_canonical_methods(monkeypatch): +def test_stable_client_surface_uses_canonical_methods(): import asyncio from app.services.design import intelligence_runtime as ir - monkeypatch.setattr(ir.settings, "intelligence_provider", "local") ir.reset_design_intelligence_client() client = ir.build_design_intelligence_client() rt = _rt() @@ -115,259 +112,3 @@ async def _run(): assert isinstance(gov, dict) assert gov.get("status") in ("pass", "fail") _ = proposed - - -def test_remote_provider_falls_back_on_empty_post(monkeypatch): - import asyncio - - from app.services.design.intelligence_runtime import ( - BasicLocalProvider, - RemoteIntelligenceProvider, - ) - - remote = RemoteIntelligenceProvider(base_url="http://example.invalid", fallback=BasicLocalProvider()) - - async def _empty(_method: str, _rt: object): - return {} - - monkeypatch.setattr(remote, "_post", _empty) - rt = _rt() - - async def _run(): - return await remote.research(rt) - - result = asyncio.run(_run()) - assert result is not None or getattr(rt, "design_research", None) is not None - - -def test_remote_provider_applies_usable_research(monkeypatch): - import asyncio - - from app.services.design.intelligence_runtime import ( - BasicLocalProvider, - RemoteIntelligenceProvider, - apply_intelligence_result, - ) - - remote = RemoteIntelligenceProvider( - base_url="http://example.invalid", - fallback=BasicLocalProvider(), - apply_result=apply_intelligence_result, - ) - payload = { - "category": "ai_landing", - "common_patterns": ["purple-blue gradient"], - "avoid": ["purple gradient"], - "anti_category_strategy": [ - "avoid: purple gradient", - "adopt: editorial typography", - ], - "why_effective": ["Category clichés erase differentiation."], - "summary": "category=ai_landing", - "provider": "private-research", - } - - async def _ok(_method: str, _rt: object): - return payload - - monkeypatch.setattr(remote, "_post", _ok) - rt = _rt() - - async def _run(): - return await remote.research(rt) - - result = asyncio.run(_run()) - assert result is not None - assert rt.design_research is not None - assert rt.design_research.get("category") == "ai_landing" - assert rt.design_research.get("provider") == "private-research" - assert "tool_ops" not in (rt.design_research or {}) - - -def test_remote_applies_advanced_hooks(monkeypatch): - import asyncio - - from app.services.design.intelligence_runtime import ( - BasicLocalProvider, - RemoteIntelligenceProvider, - apply_intelligence_result, - ) - - remote = RemoteIntelligenceProvider( - base_url="http://example.invalid", - fallback=BasicLocalProvider(), - apply_result=apply_intelligence_result, - ) - - async def _post(method: str, _rt: object): - if method == "retrieve_memory": - return { - "notes": ["preference:premium_restraint"], - "summary": "memory", - "provider": "private-memory", - } - if method == "review": - return { - "status": "pass", - "score": 90, - "issues": [], - "summary": "ok", - "provider": "private-review", - } - if method == "optimize": - return { - "actions": ["raise CTA"], - "applied": False, - "summary": "opt", - "provider": "private-optimize", - } - if method == "write_principle": - return { - "principles": ["thesis:x"], - "written": True, - "summary": "wrote", - "provider": "private-principle", - } - return {} - - monkeypatch.setattr(remote, "_post", _post) - rt = _rt() - - async def _run(): - await remote.retrieve_memory(rt) - await remote.review(rt) - await remote.optimize(rt) - await remote.write_principle(rt) - - asyncio.run(_run()) - assert "preference:premium_restraint" in (rt.flags.get("memory_notes") or []) - assert (rt.judge_verdict or {}).get("score") == 90 - assert (rt.optimization or {}).get("actions") - assert rt.flags.get("knowledge_written") is True - - -class _StubResearch: - async def research(self, _rt: object): - return {"summary": "stub-local", "provider": "stub"} - - -def test_remote_builds_request_once_per_call(monkeypatch): - import asyncio - - from app.services.design.intelligence_runtime import RemoteIntelligenceProvider - - builds: list[str] = [] - remote = RemoteIntelligenceProvider(base_url="http://example.invalid") - - def _count(method: str, rt: object): - builds.append(method) - from recombyn_runtime.intelligence import build_intelligence_request as real - - return real(method, rt) - - monkeypatch.setattr( - "recombyn_intelligence_client.remote.build_intelligence_request", - _count, - ) - - async def _ok(_method: str, _payload: object): - return {"summary": "ok", "provider": "remote"} - - monkeypatch.setattr(remote, "_post", _ok) - - async def _run(): - return await remote.research(_rt()) - - result = asyncio.run(_run()) - assert result is not None - assert builds == ["research"] - - -def test_circuit_skips_http_after_timeout(monkeypatch): - import asyncio - import httpx - - from app.services.design.intelligence_runtime import RemoteIntelligenceProvider - - posts: list[str] = [] - - class _FailClient: - is_closed = False - - async def post(self, url: str, json=None, headers=None): - posts.append(url) - raise httpx.TimeoutException("timeout") - - fail_client = _FailClient() - remote = RemoteIntelligenceProvider( - base_url="http://example.invalid", - timeout_sec=8.0, - circuit_sec=30.0, - fallback=_StubResearch(), - ) - - async def _http(): - return fail_client - - monkeypatch.setattr(remote, "_http_client", _http) - - async def _run(): - first = await remote.research(_rt()) - second = await remote.research(_rt()) - return first, second - - first, second = asyncio.run(_run()) - assert first == {"summary": "stub-local", "provider": "stub"} - assert second == {"summary": "stub-local", "provider": "stub"} - assert len(posts) == 1 - assert remote._circuit_open() is True - - -def test_hop_cache_skips_http_on_resume(monkeypatch): - import asyncio - - from app.services.design.intelligence_runtime import RemoteIntelligenceProvider - - store: dict[tuple[str, str, str], dict] = {} - posts: list[str] = [] - - def hop_get(key: tuple[str, str, str]): - return store.get(key) - - def hop_put(key: tuple[str, str, str], payload: dict): - store[key] = payload - - async def _ok(method: str, _payload: object): - posts.append(method) - return {"summary": "cached", "provider": "remote"} - - first = RemoteIntelligenceProvider( - base_url="http://example.invalid", - hop_get=hop_get, - hop_put=hop_put, - ) - monkeypatch.setattr(first, "_post", _ok) - - async def _run_first(): - return await first.research(_rt()) - - assert asyncio.run(_run_first())["summary"] == "cached" - assert posts == ["research"] - - resumed = RemoteIntelligenceProvider( - base_url="http://example.invalid", - hop_get=hop_get, - hop_put=hop_put, - ) - - async def _forbidden(_method: str, _payload: object): - posts.append("should-not-post") - return {"summary": "fresh", "provider": "remote"} - - monkeypatch.setattr(resumed, "_post", _forbidden) - - async def _run_resume(): - return await resumed.research(_rt()) - - assert asyncio.run(_run_resume())["summary"] == "cached" - assert posts == ["research"] diff --git a/apps/api/tests/integration_tests/test_design_canvas_crud.py b/apps/api/tests/integration_tests/test_design_canvas_crud.py index e7986a73..694584df 100644 --- a/apps/api/tests/integration_tests/test_design_canvas_crud.py +++ b/apps/api/tests/integration_tests/test_design_canvas_crud.py @@ -98,10 +98,6 @@ def _wallet_and_fast_observe(monkeypatch): "app.services.design.runtime.graph.nodes.observe._SCENE_WAIT_SEC", 0.05, ) - monkeypatch.setattr( - "app.core.config.settings.intelligence_provider", - "local", - ) monkeypatch.setattr( "app.services.design.runtime.graph.nodes.decide.intelligence_task_profile", lambda _rt: IntelligenceTaskProfile("direct", (), (), False, False), diff --git a/apps/api/tests/integration_tests/test_design_golden_paths.py b/apps/api/tests/integration_tests/test_design_golden_paths.py index 5b87aecd..205275c4 100644 --- a/apps/api/tests/integration_tests/test_design_golden_paths.py +++ b/apps/api/tests/integration_tests/test_design_golden_paths.py @@ -55,10 +55,6 @@ def _wallet(monkeypatch): "app.services.design.runtime.orchestrator._refund_hold", lambda *_a, **_k: None, ) - monkeypatch.setattr( - "app.core.config.settings.intelligence_provider", - "local", - ) monkeypatch.setattr( "app.services.design.runtime.graph.nodes.decide.intelligence_task_profile", lambda _rt: IntelligenceTaskProfile("direct", (), (), False, False), diff --git a/apps/api/tests/unit_tests/test_free_vision_quota.py b/apps/api/tests/unit_tests/test_free_vision_quota.py new file mode 100644 index 00000000..a544e157 --- /dev/null +++ b/apps/api/tests/unit_tests/test_free_vision_quota.py @@ -0,0 +1,63 @@ +"""Free-plan lifetime vision toolbar quota (3 uses).""" + +from __future__ import annotations + +import pytest +from fastapi import HTTPException + + +def test_require_free_vision_allows_when_cost_positive(monkeypatch): + from app.api.routes import image_tools as mod + + monkeypatch.setattr(mod, "is_wallet_billing_enabled", lambda: True) + monkeypatch.setattr(mod, "get_user_plan", lambda _uid: "free") + called = {"n": 0} + + def _consume(_uid: str) -> bool: + called["n"] += 1 + return True + + monkeypatch.setattr(mod, "consume_free_vision_quota", _consume) + mod._require_free_vision_quota("u1", cost=30, locale="en") + assert called["n"] == 0 + + +def test_require_free_vision_skips_paid_plan(monkeypatch): + from app.api.routes import image_tools as mod + + monkeypatch.setattr(mod, "is_wallet_billing_enabled", lambda: True) + monkeypatch.setattr(mod, "get_user_plan", lambda _uid: "pro") + called = {"n": 0} + + def _consume(_uid: str) -> bool: + called["n"] += 1 + return True + + monkeypatch.setattr(mod, "consume_free_vision_quota", _consume) + mod._require_free_vision_quota("u1", cost=0, locale="en") + assert called["n"] == 0 + + +def test_require_free_vision_exhausted_raises_402(monkeypatch): + from app.api.routes import image_tools as mod + + monkeypatch.setattr(mod, "is_wallet_billing_enabled", lambda: True) + monkeypatch.setattr(mod, "get_user_plan", lambda _uid: "free") + monkeypatch.setattr(mod, "consume_free_vision_quota", lambda _uid: False) + + with pytest.raises(HTTPException) as ei: + mod._require_free_vision_quota("u1", cost=0, locale="zh-CN") + assert ei.value.status_code == 402 + detail = ei.value.detail + assert isinstance(detail, dict) + assert detail.get("code") == "free_vision_exhausted" + assert "3" in str(detail.get("message") or "") + + +def test_require_free_vision_consumes_when_available(monkeypatch): + from app.api.routes import image_tools as mod + + monkeypatch.setattr(mod, "is_wallet_billing_enabled", lambda: True) + monkeypatch.setattr(mod, "get_user_plan", lambda _uid: "free") + monkeypatch.setattr(mod, "consume_free_vision_quota", lambda _uid: True) + mod._require_free_vision_quota("u1", cost=0, locale="en") diff --git a/apps/api/tests/unit_tests/test_vision_image_refs.py b/apps/api/tests/unit_tests/test_vision_image_refs.py new file mode 100644 index 00000000..ddb9442d --- /dev/null +++ b/apps/api/tests/unit_tests/test_vision_image_refs.py @@ -0,0 +1,95 @@ +"""Seedream / WaveSpeed image refs must not send localhost to remote APIs.""" + +from __future__ import annotations + +import asyncio +import base64 + +import pytest + + +def test_seedream_public_url_passed_through(): + from app.services.vision.providers.seedream import _ensure_image_ref + + out = asyncio.run( + _ensure_image_ref("https://cdn.example.com/a.png", user_id="u1") + ) + assert out == "https://cdn.example.com/a.png" + + +def test_seedream_rewrites_localhost_to_vision_public_base(monkeypatch): + from app.services.vision.providers import seedream as mod + + monkeypatch.setattr( + "app.core.config.settings.s3_public_base_url", + "http://localhost:9000/recombyn", + ) + monkeypatch.setattr( + "app.core.config.settings.vision_public_base_url", + "https://files.recombyn.com/recombyn", + ) + out = asyncio.run( + mod._ensure_image_ref( + "http://localhost:9000/recombyn/uploads/user/a.png", + user_id="u1", + ) + ) + assert out == "https://files.recombyn.com/recombyn/uploads/user/a.png" + + +def test_seedream_localhost_inlined_when_no_public_base(monkeypatch): + from app.services.vision.providers import seedream as mod + + png = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + ) + + async def fake_download(_url: str) -> bytes: + return png + + monkeypatch.setattr( + "app.core.config.settings.s3_public_base_url", + "http://localhost:9000/recombyn", + ) + monkeypatch.setattr("app.core.config.settings.vision_public_base_url", "") + monkeypatch.setattr(mod, "_download", fake_download) + out = asyncio.run( + mod._ensure_image_ref( + "http://localhost:9000/recombyn/uploads/x.png", + user_id="u1", + ) + ) + assert out.startswith("data:image/png;base64,") + assert base64.b64decode(out.split(",", 1)[1]) == png + + +def test_wavespeed_localhost_inlined_when_rehost_still_private(monkeypatch): + from app.services.vision.providers import wavespeed as mod + + png = b"\x89PNG\r\n\x1a\n" + b"x" * 16 + + async def fake_load(_ref: str) -> tuple[bytes, str]: + return png, "image/png" + + monkeypatch.setattr(mod, "_load_image_bytes", fake_load) + monkeypatch.setattr( + mod, + "rehost_image_bytes", + lambda *_a, **_k: "http://127.0.0.1:9000/bucket/x.png", + ) + out = asyncio.run( + mod.ensure_public_image_url( + "http://localhost:9000/recombyn/uploads/x.png", + user_id="u1", + ) + ) + assert out.startswith("data:image/png;base64,") + + +def test_wavespeed_keeps_public_http(): + from app.services.vision.providers.wavespeed import ensure_public_image_url + + out = asyncio.run( + ensure_public_image_url("https://cdn.example.com/a.png", user_id="u1") + ) + assert out == "https://cdn.example.com/a.png" diff --git a/apps/api/tests/unit_tests/test_wavespeed_vision.py b/apps/api/tests/unit_tests/test_wavespeed_vision.py index f544ef1f..feae6013 100644 --- a/apps/api/tests/unit_tests/test_wavespeed_vision.py +++ b/apps/api/tests/unit_tests/test_wavespeed_vision.py @@ -64,7 +64,7 @@ def test_angle_mapping(): assert ws.num_layers_from_meta({"num_layers": 1}) == 2 -def test_credit_cost_vision_kinds_zero(): +def test_credit_cost_vision_kinds(): from app.api.routes.image_tools import credit_cost_for_kind assert credit_cost_for_kind("upscale") == 0 @@ -72,7 +72,7 @@ def test_credit_cost_vision_kinds_zero(): assert credit_cost_for_kind("removeBg") == 0 assert credit_cost_for_kind("translateImage") == 0 assert credit_cost_for_kind("productScene") == 0 - assert credit_cost_for_kind("editElements") == 0 + assert credit_cost_for_kind("editElements") == 30 assert credit_cost_for_kind("multiAngle") == 0 assert credit_cost_for_kind("replaceText") == 30 diff --git a/apps/web/src/components/editor/nodes/ImageNode/ImageProcessWatcher.tsx b/apps/web/src/components/editor/nodes/ImageNode/ImageProcessWatcher.tsx index b44173ce..d8c3159a 100644 --- a/apps/web/src/components/editor/nodes/ImageNode/ImageProcessWatcher.tsx +++ b/apps/web/src/components/editor/nodes/ImageNode/ImageProcessWatcher.tsx @@ -1,355 +1,373 @@ -import { useEffect, useRef, memo } from 'react'; -import { useSelector } from '@/store'; -import { useEditorDocument } from '@/store/editorSelectors'; -import i18n from '@/i18n'; -import { message } from '@/components/base'; -import { - formatProcessProgressLabel, - processJobAttrPatch, - readProcessJobIds, - stripProcessProgressLabel, -} from '@/components/rcb/scene/document/processJobAttrs'; -import { - AI_IMAGE_PROCESS_KINDS, - processImageToolAsync, - useImageToolCapabilities, - type ImageProcessResult, -} from '@/service/imageTools'; -import { isUploadAbortError, uploadImageFromSrc } from '@/utils/uploadImage'; -import { getHttpErrorMessage } from '@/service/client'; -import { refreshWalletAfterSpend } from '@/service/wallet'; -import { - failImageProcess, - finishImageProcess, - patchDocumentNode, -} from '@/store/modules/editor'; -import type { SceneNodeInput } from '@/components/rcb/sceneNode'; - -const DECOMPOSE_KINDS = new Set(['editText', 'editElements']); - -function tt(key: string, opts?: Record): string { - return String(i18n.t(key, opts)); -} - -function parseMeta(raw: unknown): Record { - if (!raw) return {}; - if (typeof raw === 'object') return raw as Record; - try { - return JSON.parse(String(raw)) as Record; - } catch { - return {}; - } -} - -function aspectFromBox(w: number, h: number): string { - const rw = Math.max(1, Math.round(w)); - const rh = Math.max(1, Math.round(h)); - const g = (a: number, b: number): number => (b === 0 ? a : g(b, a % b)); - const d = g(rw, rh) || 1; - return `${Math.round(rw / d)}:${Math.round(rh / d)}`; -} - -function resolutionFor(kind: string, node: SceneNodeInput): string | undefined { - if (kind !== 'upscale') return undefined; - const meta = parseMeta(node?.attrs?.processMeta); - const fromMeta = String(meta.resolution || '') - .trim() - .toUpperCase(); - if (fromMeta === '2K' || fromMeta === '4K') return fromMeta; - const tw = Number(node?.attrs?.processTargetWidth) || 0; - if (tw >= 3500) return '4K'; - return '2K'; -} - -/** Persist tool output on our file server. */ -async function persistProcessedSrc(src: string, filename: string): Promise { - const raw = String(src || '').trim(); - if (!raw) throw new Error('empty processed image'); - const uploaded = await uploadImageFromSrc(raw, filename); - const url = String(uploaded.url || '').trim(); - if (!url) throw new Error('upload returned no url'); - return url; -} - -async function refreshWallet() { - refreshWalletAfterSpend(); -} - -/** Prefer backend ``message``; FE i18n only for client-only / empty fallbacks. */ -function processFailMessage(err: unknown): string { - const msg = getHttpErrorMessage(err, ''); - if (msg.trim()) return msg; - if ( - /timeout/i.test(String((err as Error)?.message || '')) || - (err as { code?: string })?.code === 'ECONNABORTED' - ) { - return tt('editor.imageToolbar.processTimeout'); - } - return tt('editor.imageToolbar.processFailed'); -} - -function buildFinishAttrsForKind( - kind: string, - opts: { - sourceGenPrompt?: string; - replacedCopy?: string; - } -): Record | undefined { - if (kind === 'removeBg' || kind === 'eraser') { - return { - cutout: 'true', - name: - kind === 'eraser' - ? tt('editor.imageToolbar.nameEraser') - : tt('editor.imageToolbar.nameCutout'), - }; - } - if (kind === 'replaceText' && opts.replacedCopy) { - return { - letteringText: opts.replacedCopy, - genPrompt: [String(opts.sourceGenPrompt || '').trim(), `Text replaced to: ${opts.replacedCopy}`] - .filter(Boolean) - .join('\n'), - }; - } - return undefined; -} - -async function finishDecomposeResult( - pendingId: string, - kind: string, - res: ImageProcessResult, - cancelled: () => boolean -) { - const layers = Array.isArray(res?.layers) ? res.layers : []; - if (!layers.length || !DECOMPOSE_KINDS.has(kind)) return false; - - const persisted = await Promise.all( - layers.map(async (layer: any, i: number) => { - const src = String(layer?.src || '').trim(); - if (!src || String(layer?.type) === 'text' || /^https?:\/\//i.test(src)) return layer; - return { ...layer, src: await persistProcessedSrc(src, `${kind}-layer-${i + 1}.png`) }; - }) - ); - if (cancelled()) return true; - - finishImageProcess({ - nodeId: pendingId, - layers: persisted, - sourceWidth: Number(res.width) || undefined, - sourceHeight: Number(res.height) || undefined, - }); - const warn = Array.isArray(res.warnings) ? res.warnings.filter(Boolean) : []; - if (warn.length) { - message.warning(warn.slice(0, 3).join(';')); - } else { - const textCount = layers.filter((l: any) => String(l?.type) === 'text').length; - const rasterCount = layers.filter((l: any) => l?.letteringText).length; - if (kind === 'editElements') { - message.success(tt('editor.imageToolbar.doneEditElementsHint')); - } else if (rasterCount > 0 && textCount > 0) { - message.success( - tt('editor.imageToolbar.doneOcrMixed', { textCount, rasterCount }) - ); - } else if (textCount > 0) { - message.success(tt('editor.imageToolbar.doneOcrEditable', { count: textCount })); - } else { - message.success(tt('editor.imageToolbar.doneOcr')); - } - } - await refreshWallet(); - return true; -} - -/** - * Completes spawned image process jobs via async backend jobs + SSE progress. - * Results are uploaded to our file server when still inline data URLs. - */ -function ImageProcessWatcher() { useImageToolCapabilities(); - const pendingId = useSelector((s: any) => s.editor.pendingImageProcessId as string | null); - const document = useEditorDocument(); - const documentRef = useRef(document); - documentRef.current = document; - - useEffect(() => { - if (!pendingId) return undefined; - const doc = documentRef.current; - const node = doc?.deltaSetLike?.[pendingId]; - const kind = String(node?.attrs?.processKind || ''); - if (kind === 'import' || kind === 'upload') return undefined; - - let cancelled = false; - const ac = new AbortController(); - const isCancelled = () => cancelled; - - const fail = (msg: string) => { - if (cancelled) return; - message.error(msg); - failImageProcess({ nodeId: pendingId }); - }; - - const run = async () => { - if (!AI_IMAGE_PROCESS_KINDS.has(kind)) { - fail(`unsupported image process kind: ${kind || 'unknown'}`); - return; - } - - const latest = documentRef.current; - const liveNode = latest?.deltaSetLike?.[pendingId] || node; - const sourceId = String(liveNode?.attrs?.processSourceId || ''); - const sourceNode = sourceId ? latest?.deltaSetLike?.[sourceId] : null; - const image = String(sourceNode?.attrs?.src || liveNode?.attrs?.src || ''); - if (!image) { - fail(tt('editor.imageToolbar.imageNotFound')); - return; - } - - const w = Number(liveNode?.width) || Number(sourceNode?.width) || 1024; - const h = Number(liveNode?.height) || Number(sourceNode?.height) || 1024; - const meta = parseMeta(liveNode?.attrs?.processMeta); - const labelBase = stripProcessProgressLabel(String(liveNode?.attrs?.processLabel || '')); - - try { - const processBody: { - kind: string; - image: string; - meta?: Record; - aspect_ratio?: string; - quality?: string; - resolution?: string; - } = { - kind, - image, - quality: 'high', - }; - if (meta) processBody.meta = meta; - const aspect = aspectFromBox(w, h); - if (aspect) processBody.aspect_ratio = aspect; - const resolution = resolutionFor(kind, liveNode); - if (resolution) processBody.resolution = resolution; - - const existingJobIds = readProcessJobIds(liveNode); - const res = await processImageToolAsync(processBody, { - signal: ac.signal, - jobId: existingJobIds[0], - onProgress: (pct) => { - if (cancelled) return; - patchDocumentNode({ - nodeId: pendingId, - skipHistory: true, - patch: { - attrs: { - processLabel: formatProcessProgressLabel( - labelBase, - pct, - labelBase || '处理中' - ), - }, - }, - }); - }, - onJobCreated: (jobId) => { - if (cancelled) return; - patchDocumentNode({ - nodeId: pendingId, - skipHistory: true, - patch: { attrs: processJobAttrPatch([jobId]) }, - }); - }, - }); - if (cancelled) return; - - if (await finishDecomposeResult(pendingId, kind, res, isCancelled)) return; - - const svgMarkup = String(res?.svg || '').trim(); - if (kind === 'vector' || svgMarkup) { - if (!svgMarkup) { - fail(tt('editor.imageToolbar.processNoResult')); - return; - } - finishImageProcess({ - nodeId: pendingId, - svg: svgMarkup, - attrs: { name: tt('editor.imageToolbar.nameVector') }, - }); - message.success(tt('editor.imageToolbar.doneVector')); - await refreshWallet(); - return; - } - - if (!res?.image) { - fail(tt('editor.imageToolbar.processNoResult')); - return; - } - const storedUrl = await persistProcessedSrc(res.image, `${kind}.png`); - if (cancelled) return; - - const batchUrls = Array.isArray(res.images) - ? res.images.map((u) => String(u || '').trim()).filter(Boolean) - : []; - let variantAttr: string | undefined; - if (batchUrls.length > 1) { - const persisted: string[] = []; - for (let i = 0; i < batchUrls.length; i += 1) { - const u = batchUrls[i]; - if (u === res.image) { - persisted.push(storedUrl); - continue; - } - const nextUrl = await persistProcessedSrc(u, `${kind}-${i}.png`); - if (cancelled) return; - persisted.push(nextUrl); - } - const withMain = persisted.includes(storedUrl) - ? persisted - : [storedUrl, ...persisted]; - variantAttr = JSON.stringify(withMain); - } - - const replaceMeta = kind === 'replaceText' ? parseMeta(liveNode?.attrs?.processMeta) : {}; - const replacedCopy = String(replaceMeta.newText || '').trim(); - const finishAttrs = buildFinishAttrsForKind(kind, { - sourceGenPrompt: String(sourceNode?.attrs?.genPrompt || ''), - replacedCopy, - }); - finishImageProcess({ - nodeId: pendingId, - src: storedUrl, - attrs: { - ...(finishAttrs || {}), - ...(variantAttr ? { imageVariants: variantAttr } : {}), - }, - }); - const labels: Record = { - removeBg: tt('editor.imageToolbar.doneRemoveBg'), - eraser: tt('editor.imageToolbar.doneEraser'), - upscale: tt('editor.imageToolbar.doneUpscale'), - multiAngle: tt('editor.imageToolbar.doneMultiAngle'), - expand: tt('editor.imageToolbar.doneExpand'), - editText: tt('editor.imageToolbar.doneEditText'), - editElements: tt('editor.imageToolbar.doneEditElements'), - replaceText: tt('editor.imageToolbar.doneReplaceText'), - translateImage: tt('editor.imageToolbar.doneTranslateImage'), - productScene: tt('editor.imageToolbar.doneProductScene'), - vector: tt('editor.imageToolbar.doneVector'), - adjust: tt('editor.imageToolbar.doneAdjust'), - }; - message.success(labels[kind] || tt('editor.imageToolbar.doneGeneric')); - await refreshWallet(); - } catch (err: any) { - if (cancelled || isUploadAbortError(err)) return; - fail(processFailMessage(err)); - } - }; - - run(); - return () => { - cancelled = true; - ac.abort(); - }; - }, [pendingId]); - - return null; -} - -export default memo(ImageProcessWatcher); +import { useEffect, useRef, memo } from 'react'; +import { useSelector } from '@/store'; +import { useEditorDocument } from '@/store/editorSelectors'; +import i18n from '@/i18n'; +import { message } from '@/components/base'; +import { + formatProcessProgressLabel, + processJobAttrPatch, + readProcessJobIds, + stripProcessProgressLabel, +} from '@/components/rcb/scene/document/processJobAttrs'; +import { + AI_IMAGE_PROCESS_KINDS, + processImageToolAsync, + useImageToolCapabilities, + type ImageProcessResult, +} from '@/service/imageTools'; +import { isUploadAbortError, uploadImageFromSrc } from '@/utils/uploadImage'; +import { getHttpErrorMessage, getHttpStatus } from '@/service/client'; +import { refreshWalletAfterSpend } from '@/service/wallet'; +import { + failImageProcess, + finishImageProcess, + patchDocumentNode, +} from '@/store/modules/editor'; +import type { SceneNodeInput } from '@/components/rcb/sceneNode'; + +const DECOMPOSE_KINDS = new Set(['editText', 'editElements']); + +function tt(key: string, opts?: Record): string { + return String(i18n.t(key, opts)); +} + +function resolveHttpStatus(err: unknown): number | undefined { + const fromClient = getHttpStatus(err); + if (fromClient != null) return fromClient; + if (!err || typeof err !== 'object' || !('status' in err)) return undefined; + const s = Number((err as { status?: unknown }).status); + if (!Number.isFinite(s)) return undefined; + return s; +} + +function isQuotaWarnError(err: unknown): boolean { + if (resolveHttpStatus(err) === 402) return true; + if (!err || typeof err !== 'object' || !('code' in err)) return false; + const code = String((err as { code?: unknown }).code || '').trim(); + return code === 'free_vision_exhausted' || code === 'insufficient_credits'; +} + +function parseMeta(raw: unknown): Record { + if (!raw) return {}; + if (typeof raw === 'object') return raw as Record; + try { + return JSON.parse(String(raw)) as Record; + } catch { + return {}; + } +} + +function aspectFromBox(w: number, h: number): string { + const rw = Math.max(1, Math.round(w)); + const rh = Math.max(1, Math.round(h)); + const g = (a: number, b: number): number => (b === 0 ? a : g(b, a % b)); + const d = g(rw, rh) || 1; + return `${Math.round(rw / d)}:${Math.round(rh / d)}`; +} + +function resolutionFor(kind: string, node: SceneNodeInput): string | undefined { + if (kind !== 'upscale') return undefined; + const meta = parseMeta(node?.attrs?.processMeta); + const fromMeta = String(meta.resolution || '') + .trim() + .toUpperCase(); + if (fromMeta === '2K' || fromMeta === '4K') return fromMeta; + const tw = Number(node?.attrs?.processTargetWidth) || 0; + if (tw >= 3500) return '4K'; + return '2K'; +} + +/** Persist tool output on our file server. */ +async function persistProcessedSrc(src: string, filename: string): Promise { + const raw = String(src || '').trim(); + if (!raw) throw new Error('empty processed image'); + const uploaded = await uploadImageFromSrc(raw, filename); + const url = String(uploaded.url || '').trim(); + if (!url) throw new Error('upload returned no url'); + return url; +} + +async function refreshWallet() { + refreshWalletAfterSpend(); +} + +/** Prefer backend ``message``; FE i18n only for client-only / empty fallbacks. */ +function processFailMessage(err: unknown): string { + const msg = getHttpErrorMessage(err, ''); + if (msg.trim()) return msg; + if ( + /timeout/i.test(String((err as Error)?.message || '')) || + (err as { code?: string })?.code === 'ECONNABORTED' + ) { + return tt('editor.imageToolbar.processTimeout'); + } + return tt('editor.imageToolbar.processFailed'); +} + +function buildFinishAttrsForKind( + kind: string, + opts: { + sourceGenPrompt?: string; + replacedCopy?: string; + } +): Record | undefined { + if (kind === 'removeBg' || kind === 'eraser') { + return { + cutout: 'true', + name: + kind === 'eraser' + ? tt('editor.imageToolbar.nameEraser') + : tt('editor.imageToolbar.nameCutout'), + }; + } + if (kind === 'replaceText' && opts.replacedCopy) { + return { + letteringText: opts.replacedCopy, + genPrompt: [String(opts.sourceGenPrompt || '').trim(), `Text replaced to: ${opts.replacedCopy}`] + .filter(Boolean) + .join('\n'), + }; + } + return undefined; +} + +async function finishDecomposeResult( + pendingId: string, + kind: string, + res: ImageProcessResult, + cancelled: () => boolean +) { + const layers = Array.isArray(res?.layers) ? res.layers : []; + if (!layers.length || !DECOMPOSE_KINDS.has(kind)) return false; + + const persisted = await Promise.all( + layers.map(async (layer: any, i: number) => { + const src = String(layer?.src || '').trim(); + if (!src || String(layer?.type) === 'text' || /^https?:\/\//i.test(src)) return layer; + return { ...layer, src: await persistProcessedSrc(src, `${kind}-layer-${i + 1}.png`) }; + }) + ); + if (cancelled()) return true; + + finishImageProcess({ + nodeId: pendingId, + layers: persisted, + sourceWidth: Number(res.width) || undefined, + sourceHeight: Number(res.height) || undefined, + }); + const warn = Array.isArray(res.warnings) ? res.warnings.filter(Boolean) : []; + if (warn.length) { + message.warning(warn.slice(0, 3).join(';')); + } else { + const textCount = layers.filter((l: any) => String(l?.type) === 'text').length; + const rasterCount = layers.filter((l: any) => l?.letteringText).length; + if (kind === 'editElements') { + message.success(tt('editor.imageToolbar.doneEditElementsHint')); + } else if (rasterCount > 0 && textCount > 0) { + message.success( + tt('editor.imageToolbar.doneOcrMixed', { textCount, rasterCount }) + ); + } else if (textCount > 0) { + message.success(tt('editor.imageToolbar.doneOcrEditable', { count: textCount })); + } else { + message.success(tt('editor.imageToolbar.doneOcr')); + } + } + await refreshWallet(); + return true; +} + +/** + * Completes spawned image process jobs via async backend jobs + SSE progress. + * Results are uploaded to our file server when still inline data URLs. + */ +function ImageProcessWatcher() { + useImageToolCapabilities(); + const pendingId = useSelector((s: any) => s.editor.pendingImageProcessId as string | null); + const document = useEditorDocument(); + const documentRef = useRef(document); + documentRef.current = document; + + useEffect(() => { + if (!pendingId) return undefined; + const doc = documentRef.current; + const node = doc?.deltaSetLike?.[pendingId]; + const kind = String(node?.attrs?.processKind || ''); + if (kind === 'import' || kind === 'upload') return undefined; + + let cancelled = false; + const ac = new AbortController(); + const isCancelled = () => cancelled; + + const fail = (msg: string, err?: unknown) => { + if (cancelled) return; + if (err && isQuotaWarnError(err)) message.warning(msg); + else message.error(msg); + failImageProcess({ nodeId: pendingId }); + }; + + const run = async () => { + if (!AI_IMAGE_PROCESS_KINDS.has(kind)) { + fail(`unsupported image process kind: ${kind || 'unknown'}`); + return; + } + + const latest = documentRef.current; + const liveNode = latest?.deltaSetLike?.[pendingId] || node; + const sourceId = String(liveNode?.attrs?.processSourceId || ''); + const sourceNode = sourceId ? latest?.deltaSetLike?.[sourceId] : null; + const image = String(sourceNode?.attrs?.src || liveNode?.attrs?.src || ''); + if (!image) { + fail(tt('editor.imageToolbar.imageNotFound')); + return; + } + + const w = Number(liveNode?.width) || Number(sourceNode?.width) || 1024; + const h = Number(liveNode?.height) || Number(sourceNode?.height) || 1024; + const meta = parseMeta(liveNode?.attrs?.processMeta); + const labelBase = stripProcessProgressLabel(String(liveNode?.attrs?.processLabel || '')); + + try { + const processBody: { + kind: string; + image: string; + meta?: Record; + aspect_ratio?: string; + quality?: string; + resolution?: string; + } = { + kind, + image, + quality: 'high', + }; + if (meta) processBody.meta = meta; + const aspect = aspectFromBox(w, h); + if (aspect) processBody.aspect_ratio = aspect; + const resolution = resolutionFor(kind, liveNode); + if (resolution) processBody.resolution = resolution; + + const existingJobIds = readProcessJobIds(liveNode); + const res = await processImageToolAsync(processBody, { + signal: ac.signal, + jobId: existingJobIds[0], + onProgress: (pct) => { + if (cancelled) return; + patchDocumentNode({ + nodeId: pendingId, + skipHistory: true, + patch: { + attrs: { + processLabel: formatProcessProgressLabel( + labelBase, + pct, + labelBase || '处理中' + ), + }, + }, + }); + }, + onJobCreated: (jobId) => { + if (cancelled) return; + patchDocumentNode({ + nodeId: pendingId, + skipHistory: true, + patch: { attrs: processJobAttrPatch([jobId]) }, + }); + }, + }); + if (cancelled) return; + + if (await finishDecomposeResult(pendingId, kind, res, isCancelled)) return; + + const svgMarkup = String(res?.svg || '').trim(); + if (kind === 'vector' || svgMarkup) { + if (!svgMarkup) { + fail(tt('editor.imageToolbar.processNoResult')); + return; + } + finishImageProcess({ + nodeId: pendingId, + svg: svgMarkup, + attrs: { name: tt('editor.imageToolbar.nameVector') }, + }); + message.success(tt('editor.imageToolbar.doneVector')); + await refreshWallet(); + return; + } + + if (!res?.image) { + fail(tt('editor.imageToolbar.processNoResult')); + return; + } + const storedUrl = await persistProcessedSrc(res.image, `${kind}.png`); + if (cancelled) return; + + const batchUrls = Array.isArray(res.images) + ? res.images.map((u) => String(u || '').trim()).filter(Boolean) + : []; + let variantAttr: string | undefined; + if (batchUrls.length > 1) { + const persisted: string[] = []; + for (let i = 0; i < batchUrls.length; i += 1) { + const u = batchUrls[i]; + if (u === res.image) { + persisted.push(storedUrl); + continue; + } + const nextUrl = await persistProcessedSrc(u, `${kind}-${i}.png`); + if (cancelled) return; + persisted.push(nextUrl); + } + const withMain = persisted.includes(storedUrl) + ? persisted + : [storedUrl, ...persisted]; + variantAttr = JSON.stringify(withMain); + } + + const replaceMeta = kind === 'replaceText' ? parseMeta(liveNode?.attrs?.processMeta) : {}; + const replacedCopy = String(replaceMeta.newText || '').trim(); + const finishAttrs = buildFinishAttrsForKind(kind, { + sourceGenPrompt: String(sourceNode?.attrs?.genPrompt || ''), + replacedCopy, + }); + finishImageProcess({ + nodeId: pendingId, + src: storedUrl, + attrs: { + ...(finishAttrs || {}), + ...(variantAttr ? { imageVariants: variantAttr } : {}), + }, + }); + const labels: Record = { + removeBg: tt('editor.imageToolbar.doneRemoveBg'), + eraser: tt('editor.imageToolbar.doneEraser'), + upscale: tt('editor.imageToolbar.doneUpscale'), + multiAngle: tt('editor.imageToolbar.doneMultiAngle'), + expand: tt('editor.imageToolbar.doneExpand'), + editText: tt('editor.imageToolbar.doneEditText'), + editElements: tt('editor.imageToolbar.doneEditElements'), + replaceText: tt('editor.imageToolbar.doneReplaceText'), + translateImage: tt('editor.imageToolbar.doneTranslateImage'), + productScene: tt('editor.imageToolbar.doneProductScene'), + vector: tt('editor.imageToolbar.doneVector'), + adjust: tt('editor.imageToolbar.doneAdjust'), + }; + message.success(labels[kind] || tt('editor.imageToolbar.doneGeneric')); + await refreshWallet(); + } catch (err: any) { + if (cancelled || isUploadAbortError(err)) return; + fail(processFailMessage(err), err); + } + }; + + run(); + return () => { + cancelled = true; + ac.abort(); + }; + }, [pendingId]); + + return null; +} + +export default memo(ImageProcessWatcher); diff --git a/apps/web/src/components/editor/nodes/ImageNode/UpscaleSessionHost.tsx b/apps/web/src/components/editor/nodes/ImageNode/UpscaleSessionHost.tsx index a5ecf255..e50294f6 100644 --- a/apps/web/src/components/editor/nodes/ImageNode/UpscaleSessionHost.tsx +++ b/apps/web/src/components/editor/nodes/ImageNode/UpscaleSessionHost.tsx @@ -156,7 +156,7 @@ function UpscaleSessionHost({ type="button" className={cn( imageToolBtn, - 'min-w-[5.5rem] justify-between gap-2 px-3 font-medium', + 'gap-1 px-2 font-medium', menuOpen && 'bg-[var(--accent-soft)]' )} onClick={() => setMenuOpen((v) => !v)} @@ -193,7 +193,7 @@ function UpscaleSessionHost({ {sceneMenuOpen ? ( - + {PRODUCT_SCENE_PRESETS.map((p) => ( { setSceneCode(p.code); setSceneMenuOpen(false); }} > - {t(`editor.imageToolbar.productScenePreset.${p.labelKey}`, { - defaultValue: p.code, - })} + + + {t(`editor.imageToolbar.productScenePreset.${p.labelKey}`, { + defaultValue: p.code, + })} + ))} @@ -200,7 +215,7 @@ function ProductSceneSessionHost({ type="button" className={cn( imageToolBtn, - 'min-w-[4.5rem] justify-between gap-2 px-3 font-medium tabular-nums', + 'gap-1 px-2 font-medium tabular-nums', batchMenuOpen && 'bg-[var(--accent-soft)]' )} onClick={() => { @@ -235,7 +250,7 @@ function ProductSceneSessionHost({